diff --git a/.gitignore b/.gitignore index 0bcbeb0008..bee7416181 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ eng/tools/ToolDescriptionEvaluator/prompts.json eng/tools/ToolDescriptionEvaluator/results.md eng/tools/ToolDescriptionEvaluator/tools.json deploy-log.txt + +# VM-specific documentation (internal/personal notes) +docs/vm-docs/ diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json b/core/Microsoft.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json index cf9bcb5cb1..d8575f343a 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json @@ -2239,6 +2239,74 @@ "aks_nodepool_get" ] }, + { + "name": "create_azure_compute_resources", + "description": "Create Azure compute resources including Virtual Machines (VMs) and Virtual Machine Scale Sets (VMSS). Create VMs with smart workload-based defaults (development, web, database, compute, memory, gpu, general). Supports Linux and Windows with SSH key or password authentication. Create VMSS for scalable workloads with configurable instance count and upgrade policies.", + "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": true, + "description": "This tool handles sensitive authentication information like passwords and SSH keys." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "compute_vm_create", + "compute_vmss_create" + ] + }, + { + "name": "update_azure_compute_resources", + "description": "Update Azure compute resources including 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. Uses PATCH semantics - only specified properties are updated.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool modifies existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result." + }, + "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 modifies 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_vm_update", + "compute_vmss_update" + ] + }, { "name": "get_azure_virtual_desktop_details", "description": "Get details about Azure Virtual Desktop resources. List host pools in subscriptions or resource groups. Retrieve session hosts (virtual machines) within host pools including their status, availability, and configuration. View active user sessions on session hosts with details such as user principal name, session state, application type, and creation time.", diff --git a/eng/scripts/Deploy-TestResources.ps1 b/eng/scripts/Deploy-TestResources.ps1 index a2c6da5895..849eb0791d 100644 --- a/eng/scripts/Deploy-TestResources.ps1 +++ b/eng/scripts/Deploy-TestResources.ps1 @@ -4,6 +4,7 @@ param( [string]$SubscriptionId, [string]$ResourceGroupName, [string]$BaseName, + [string]$Location, [int]$DeleteAfterHours = 12, [switch]$Unique, [switch]$Parallel, @@ -69,6 +70,7 @@ function Deploy-TestResources [string]$SubscriptionName, [string]$ResourceGroupName, [string]$BaseName, + [string]$Location, [int]$DeleteAfterHours, [string]$TestResourcesDirectory, [switch]$AsJob @@ -81,29 +83,32 @@ Deploying$($AsJob ? ' in background job' : ''): SubscriptionName: '$SubscriptionName' ResourceGroupName: '$ResourceGroupName' BaseName: '$BaseName' + Location: '$Location' DeleteAfterHours: $DeleteAfterHours TestResourcesDirectory: '$TestResourcesDirectory'`n "@ -ForegroundColor Yellow if($AsJob) { Start-Job -ScriptBlock { - param($RepoRoot, $SubscriptionId, $ResourceGroupName, $BaseName, $testResourcesDirectory, $DeleteAfterHours, $UseHttpTransport) + param($RepoRoot, $SubscriptionId, $ResourceGroupName, $BaseName, $Location, $testResourcesDirectory, $DeleteAfterHours, $UseHttpTransport) & "$RepoRoot/eng/common/TestResources/New-TestResources.ps1" ` -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName ` -BaseName $BaseName ` + -Location $Location ` -TestResourcesDirectory $testResourcesDirectory ` -DeleteAfterHours $DeleteAfterHours ` -UseHttpTransport:$UseHttpTransport ` -Force - } -ArgumentList $RepoRoot, $SubscriptionId, $ResourceGroupName, $BaseName, $TestResourcesDirectory, $DeleteAfterHours, $UseHttpTransport + } -ArgumentList $RepoRoot, $SubscriptionId, $ResourceGroupName, $BaseName, $Location, $TestResourcesDirectory, $DeleteAfterHours, $UseHttpTransport } else { & "$RepoRoot/eng/common/TestResources/New-TestResources.ps1" ` -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName ` -BaseName $BaseName ` + -Location $Location ` -TestResourcesDirectory $testResourcesDirectory ` -DeleteAfterHours $DeleteAfterHours ` -UseHttpTransport:$UseHttpTransport ` @@ -129,6 +134,7 @@ $jobInputs = $testablePaths | ForEach-Object { SubscriptionName = $subscriptionName ResourceGroupName = $ResourceGroupName ? $ResourceGroupName : "$accountName-mcp$($suffix)" BaseName = $BaseName ? $BaseName : "mcp$($suffix)" + Location = $Location DeleteAfterHours = $DeleteAfterHours TestResourcesDirectory = Resolve-Path -Path "$RepoRoot/$_/tests" } diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 9b242840d7..b671a99e29 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -913,6 +913,12 @@ Check out the remote hosting [azd templates](https://github.com/microsoft/mcp/bl * "Get virtual machine 'my-vm' with instance view including power state and runtime status" * "Show me the power state and provisioning status of VM 'my-vm'" * "What is the current status of my virtual machine 'my-vm'?" +* "Create a new VM named 'my-vm' in resource group 'my-rg' for web workloads" +* "Create a Linux VM with Ubuntu 22.04 and SSH key authentication" +* "Create a development VM with Standard_B2s size in East US" +* "Update VM 'my-vm' tags to environment=production" +* "Create a VMSS named 'my-vmss' with 3 instances for web workloads" +* "Update VMSS 'my-vmss' capacity to 5 instances" ### �📦 Azure Container Apps diff --git a/servers/Azure.Mcp.Server/changelog-entries/1770833341707.yaml b/servers/Azure.Mcp.Server/changelog-entries/1770833341707.yaml new file mode 100644 index 0000000000..4e6eab6ed7 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1770833341707.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added Azure Compute tools for Virtual Machines (VMs) and Virtual Machine Scale Sets (VMSS). Includes VM get (list, details, instance view), VMSS get (list, details, VM instances), VM create/update with smart workload-based defaults, and VMSS create/update operations" diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 3acc4e9234..d8d51e5d5d 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -713,6 +713,148 @@ azmcp compute vm get --subscription "my-subscription" \ | `--vm-name`, `--name` | No | Name of the virtual machine | | `--instance-view` | No | Include instance view details (only available with `--vm-name`) | +```bash +# Create Virtual Machine with smart defaults based on workload requirements +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vm create --subscription \ + --resource-group \ + --vm-name \ + --location \ + --admin-username \ + [--admin-password ] \ + [--ssh-public-key ] \ + [--vm-size ] \ + [--image ] \ + [--workload ] \ + [--os-type ] \ + [--virtual-network ] \ + [--subnet ] \ + [--public-ip-address ] \ + [--network-security-group ] \ + [--no-public-ip] \ + [--zone ] \ + [--os-disk-size-gb ] \ + [--os-disk-type ] + +# Examples: + +# Create Linux VM with SSH key (development workload) +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vm create --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vm-name "my-linux-vm" \ + --location "eastus" \ + --admin-username "azureuser" \ + --ssh-public-key "ssh-ed25519 AAAAC3..." \ + --workload "development" + +# Create Windows VM with password +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vm create --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vm-name "my-win-vm" \ + --location "eastus" \ + --admin-username "adminuser" \ + --admin-password "ComplexPassword123!" \ + --image "Win2022Datacenter" \ + --workload "web" + +# Create VM with specific size and no public IP +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vm create --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vm-name "my-private-vm" \ + --location "eastus" \ + --admin-username "azureuser" \ + --ssh-public-key "ssh-ed25519 AAAAC3..." \ + --vm-size "Standard_D4s_v3" \ + --no-public-ip +``` + +**Workload Types:** +- `development`: Standard_B2s - Cost-effective burstable VM for dev/test +- `web`: Standard_D2s_v3 - General purpose for web servers +- `database`: Standard_E4s_v3 - Memory-optimized for databases +- `compute`: Standard_F4s_v2 - CPU-optimized for batch processing +- `memory`: Standard_E8s_v3 - High-memory for caching +- `gpu`: Standard_NC6s_v3 - GPU-enabled for ML/rendering +- `general`: Standard_D2s_v3 - Balanced general purpose (default) + +**Image Aliases:** +- Linux: `Ubuntu2404`, `Ubuntu2204`, `Ubuntu2004`, `Debian11`, `Debian12`, `RHEL9`, `CentOS8` +- Windows: `Win2022Datacenter`, `Win2019Datacenter`, `Win11Pro`, `Win10Pro` + +**Parameters:** +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--subscription` | Yes | Azure subscription ID | +| `--resource-group`, `-g` | Yes | Resource group name | +| `--vm-name` | Yes | Name of the virtual machine | +| `--location` | Yes | Azure region | +| `--admin-username` | Yes | Admin username | +| `--admin-password` | Conditional | Admin password (required for Windows, optional for Linux) | +| `--ssh-public-key` | Conditional | SSH public key (for Linux VMs) | +| `--vm-size` | No | VM size (defaults based on workload) | +| `--image` | No | Image alias or URN (default: Ubuntu2404) | +| `--workload` | No | Workload type for smart defaults | +| `--os-type` | No | OS type: 'linux' or 'windows' (auto-detected from image) | +| `--virtual-network` | No | Virtual network name | +| `--subnet` | No | Subnet name | +| `--public-ip-address` | No | Public IP address name | +| `--network-security-group` | No | Network security group name | +| `--no-public-ip` | No | Do not create a public IP address | +| `--zone` | No | Availability zone | +| `--os-disk-size-gb` | No | OS disk size in GB | +| `--os-disk-type` | No | OS disk type: 'Premium_LRS', 'StandardSSD_LRS', 'Standard_LRS' | + +```bash +# Update Virtual Machine configuration +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vm update --subscription \ + --resource-group \ + --vm-name \ + [--vm-size ] \ + [--tags ] \ + [--license-type ] \ + [--boot-diagnostics ] \ + [--user-data ] + +# Examples: + +# Add tags to a VM +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vm update --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vm-name "my-vm" \ + --tags "environment=prod,team=compute" + +# Enable Azure Hybrid Benefit +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vm update --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vm-name "my-vm" \ + --license-type "Windows_Server" + +# Enable boot diagnostics +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vm update --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vm-name "my-vm" \ + --boot-diagnostics "true" +``` + +**Parameters:** +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--subscription` | Yes | Azure subscription ID | +| `--resource-group`, `-g` | Yes | Resource group name | +| `--vm-name` | Yes | Name of the virtual machine | +| `--vm-size` | No | New VM size (may require VM to be deallocated) | +| `--tags` | No | Tags in key=value,key2=value2 format | +| `--license-type` | No | License type: 'Windows_Server', 'RHEL_BYOS', 'SLES_BYOS', 'None' | +| `--boot-diagnostics` | No | Enable or disable boot diagnostics: 'true' or 'false' | +| `--user-data` | No | Base64-encoded user data | + #### Virtual Machine Scale Sets ```bash @@ -766,6 +908,126 @@ azmcp compute vmss get --subscription "my-subscription" \ | `--vmss-name` | No | Name of the virtual machine scale set | | `--instance-id` | No | Instance ID of the VM in the scale set (requires `--vmss-name`) | +```bash +# Create Virtual Machine Scale Set with smart defaults based on workload requirements +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vmss create --subscription \ + --resource-group \ + --vmss-name \ + --location \ + --admin-username \ + [--admin-password ] \ + [--ssh-public-key ] \ + [--vm-size ] \ + [--image ] \ + [--workload ] \ + [--os-type ] \ + [--virtual-network ] \ + [--subnet ] \ + [--instance-count ] \ + [--upgrade-policy ] \ + [--zone ] \ + [--os-disk-size-gb ] \ + [--os-disk-type ] + +# Examples: + +# Create Linux VMSS with SSH key +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vmss create --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vmss-name "my-vmss" \ + --location "eastus" \ + --admin-username "azureuser" \ + --ssh-public-key "ssh-ed25519 AAAAC3..." \ + --instance-count 3 + +# Create Windows VMSS with automatic upgrade policy +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ✅ Secret | ❌ LocalRequired +azmcp compute vmss create --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vmss-name "my-win-vmss" \ + --location "eastus" \ + --admin-username "adminuser" \ + --admin-password "ComplexPassword123!" \ + --image "Win2022Datacenter" \ + --upgrade-policy "Automatic" +``` + +**Parameters:** +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--subscription` | Yes | Azure subscription ID | +| `--resource-group`, `-g` | Yes | Resource group name | +| `--vmss-name` | Yes | Name of the VMSS (max 9 chars for Windows) | +| `--location` | Yes | Azure region | +| `--admin-username` | Yes | Admin username | +| `--admin-password` | Conditional | Admin password (required for Windows) | +| `--ssh-public-key` | Conditional | SSH public key (for Linux VMSS) | +| `--vm-size` | No | VM size (defaults based on workload) | +| `--image` | No | Image alias or URN (default: Ubuntu2404) | +| `--workload` | No | Workload type for smart defaults | +| `--os-type` | No | OS type: 'linux' or 'windows' | +| `--virtual-network` | No | Virtual network name | +| `--subnet` | No | Subnet name | +| `--instance-count` | No | Number of VM instances (default: 2) | +| `--upgrade-policy` | No | Upgrade policy: 'Automatic', 'Manual', 'Rolling' (default: 'Manual') | +| `--zone` | No | Availability zone | +| `--os-disk-size-gb` | No | OS disk size in GB | +| `--os-disk-type` | No | OS disk type | + +```bash +# Update Virtual Machine Scale Set configuration +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vmss update --subscription \ + --resource-group \ + --vmss-name \ + [--capacity ] \ + [--vm-size ] \ + [--upgrade-policy ] \ + [--overprovision] \ + [--enable-auto-os-upgrade] \ + [--scale-in-policy ] \ + [--tags ] + +# Examples: + +# Scale VMSS to 5 instances +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vmss update --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vmss-name "my-vmss" \ + --capacity 5 + +# Enable automatic OS upgrades +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vmss update --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vmss-name "my-vmss" \ + --enable-auto-os-upgrade true + +# Set scale-in policy to remove oldest VMs first +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute vmss update --subscription "my-subscription" \ + --resource-group "my-rg" \ + --vmss-name "my-vmss" \ + --scale-in-policy "OldestVM" +``` + +**Parameters:** +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--subscription` | Yes | Azure subscription ID | +| `--resource-group`, `-g` | Yes | Resource group name | +| `--vmss-name` | Yes | Name of the VMSS | +| `--capacity` | No | Number of VM instances | +| `--vm-size` | No | VM size | +| `--upgrade-policy` | No | Upgrade policy: 'Automatic', 'Manual', 'Rolling' | +| `--overprovision` | No | Enable or disable overprovisioning | +| `--enable-auto-os-upgrade` | No | Enable automatic OS image upgrades | +| `--scale-in-policy` | No | Scale-in policy: 'Default', 'OldestVM', 'NewestVM' | +| `--tags` | No | Tags in key=value,key2=value2 format | + #### Disks ```bash diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 19842f0e64..ac5820e0ea 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -165,6 +165,12 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| +| compute_vm_create | Create a new Linux VM named in resource group | +| compute_vm_create | Create a virtual machine for development workload in | +| compute_vm_create | Create a Windows VM with password authentication in resource group | +| compute_vm_create | Create VM in with SSH key authentication | +| compute_vm_create | Deploy a new VM for web workload in resource group | +| compute_vm_create | Create a database VM with memory-optimized size in | | compute_vm_get | List all virtual machines in my subscription | | compute_vm_get | Show me all VMs in my subscription | | compute_vm_get | What virtual machines do I have? | @@ -179,6 +185,16 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | compute_vm_get | What is the power state of virtual machine in resource group ? | | compute_vm_get | Get VM status and provisioning state in resource group | | compute_vm_get | Show me the current status of VM | +| compute_vm_update | Add tags to VM in resource group | +| compute_vm_update | Update virtual machine with environment=production tag | +| compute_vm_update | Enable Azure Hybrid Benefit on VM | +| compute_vm_update | Enable boot diagnostics for VM in resource group | +| compute_vm_update | Change the size of VM to Standard_D4s_v3 | +| compute_vm_update | Disable Hybrid Benefit license on | +| compute_vmss_create | Create a virtual machine scale set named in resource group | +| compute_vmss_create | Create a VMSS with 3 instances in | +| compute_vmss_create | Deploy a scale set for web workload with automatic upgrades | +| compute_vmss_create | Create Linux VMSS with SSH authentication in | | compute_vmss_get | List all virtual machine scale sets in my subscription | | compute_vmss_get | List virtual machine scale sets in resource group | | compute_vmss_get | What scale sets are in resource group ? | @@ -186,6 +202,12 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | compute_vmss_get | Show me VMSS in resource group | | compute_vmss_get | Show me instance of VMSS in resource group | | compute_vmss_get | What is the status of instance in scale set ? | +| compute_vmss_update | Scale VMSS to 5 instances | +| compute_vmss_update | Update the capacity of scale set to 10 | +| compute_vmss_update | Enable automatic OS upgrades on VMSS | +| compute_vmss_update | Change upgrade policy to Rolling for | +| compute_vmss_update | Set scale-in policy to OldestVM for VMSS | +| compute_vmss_update | Add tags to scale set in resource group | ## Azure Confidential Ledger diff --git a/tools/Azure.Mcp.Tools.Compute/src/Azure.Mcp.Tools.Compute.csproj b/tools/Azure.Mcp.Tools.Compute/src/Azure.Mcp.Tools.Compute.csproj index cabe25d51c..836145879c 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Azure.Mcp.Tools.Compute.csproj +++ b/tools/Azure.Mcp.Tools.Compute/src/Azure.Mcp.Tools.Compute.csproj @@ -12,6 +12,7 @@ + diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs index 96f52fb0f6..912212492c 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs @@ -15,15 +15,24 @@ namespace Azure.Mcp.Tools.Compute.Commands; [JsonSerializable(typeof(DiskGetCommand.DiskGetCommandResult))] [JsonSerializable(typeof(Models.DiskInfo))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(VmCreateCommand.VmCreateCommandResult))] +[JsonSerializable(typeof(VmCreateResult))] +[JsonSerializable(typeof(VmUpdateCommand.VmUpdateCommandResult))] +[JsonSerializable(typeof(VmUpdateResult))] [JsonSerializable(typeof(VmGetCommand.VmGetSingleResult))] [JsonSerializable(typeof(VmGetCommand.VmGetListResult))] [JsonSerializable(typeof(VmssGetCommand.VmssGetSingleResult))] [JsonSerializable(typeof(VmssGetCommand.VmssGetListResult))] [JsonSerializable(typeof(VmssGetCommand.VmssGetVmInstanceResult))] +[JsonSerializable(typeof(VmssCreateCommand.VmssCreateCommandResult))] +[JsonSerializable(typeof(VmssCreateResult))] +[JsonSerializable(typeof(VmssUpdateCommand.VmssUpdateCommandResult))] +[JsonSerializable(typeof(VmssUpdateResult))] [JsonSerializable(typeof(VmInfo))] [JsonSerializable(typeof(VmInstanceView))] [JsonSerializable(typeof(VmssInfo))] [JsonSerializable(typeof(VmssVmInfo))] +[JsonSerializable(typeof(WorkloadConfiguration))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/Vm/VmCreateCommand.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vm/VmCreateCommand.cs new file mode 100644 index 0000000000..7301f7d20a --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vm/VmCreateCommand.cs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Options; +using Azure.Mcp.Tools.Compute.Options.Vm; +using Azure.Mcp.Tools.Compute.Services; +using Azure.Mcp.Tools.Compute.Utilities; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.Compute.Commands.Vm; + +public sealed class VmCreateCommand(ILogger logger) + : BaseComputeCommand() +{ + private const string CommandTitle = "Create Virtual Machine"; + private readonly ILogger _logger = logger; + + public override string Id => "d4c9b2e7-5f3a-4b8e-9c1d-0e2f3a4b5c6d"; + + public override string Name => "create"; + + public override string Description => + """ + Create an Azure Virtual Machine with smart defaults based on workload requirements. + Supports automatic VM size selection based on workload type (development, web, database, compute, memory, gpu, general). + Creates necessary network resources (VNet, subnet, NSG, NIC, public IP) if not specified. + Supports both Linux and Windows VMs with SSH key or password authentication. + + Workload types and suggested configurations: + - development: Standard_B2s - Cost-effective burstable VM for dev/test + - web: Standard_D2s_v3 - General purpose for web servers + - database: Standard_E4s_v3 - Memory-optimized for databases + - compute: Standard_F4s_v2 - CPU-optimized for batch processing + - memory: Standard_E8s_v3 - High-memory for caching + - gpu: Standard_NC6s_v3 - GPU-enabled for ML/rendering + - general: Standard_D2s_v3 - Balanced general purpose + + Required options: + - --vm-name: Name of the VM to create + - --resource-group: Resource group name + - --subscription: Subscription ID or name + - --location: Azure region + - --admin-username: Admin username + + Authentication requirements: + - For Windows VMs: --admin-password is required + - For Linux VMs: Either --ssh-public-key OR --admin-password is required + + IMPORTANT for Linux VMs with SSH authentication: + Before calling this tool, you must first read the user's SSH public key file (typically ~/.ssh/id_rsa.pub, + ~/.ssh/id_ed25519.pub, or similar) and pass the full key content to --ssh-public-key. + The SSH public key is safe to share - it contains no secrets. + Example: --ssh-public-key "ssh-ed25519 AAAAC3... user@host" + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = true, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + LocalRequired = false, + Secret = true + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + + // Required options + command.Options.Add(ComputeOptionDefinitions.VmName.AsRequired()); + command.Options.Add(ComputeOptionDefinitions.Location.AsRequired()); + command.Options.Add(ComputeOptionDefinitions.AdminUsername.AsRequired()); + + // Authentication options (at least one required - validated in command) + command.Options.Add(ComputeOptionDefinitions.AdminPassword); + command.Options.Add(ComputeOptionDefinitions.SshPublicKey); + + // Optional configuration + command.Options.Add(ComputeOptionDefinitions.VmSize); + command.Options.Add(ComputeOptionDefinitions.Image); + command.Options.Add(ComputeOptionDefinitions.Workload); + command.Options.Add(ComputeOptionDefinitions.OsType); + + // Network options + command.Options.Add(ComputeOptionDefinitions.VirtualNetwork); + command.Options.Add(ComputeOptionDefinitions.Subnet); + command.Options.Add(ComputeOptionDefinitions.PublicIpAddress); + command.Options.Add(ComputeOptionDefinitions.NetworkSecurityGroup); + command.Options.Add(ComputeOptionDefinitions.NoPublicIp); + + // Additional options + command.Options.Add(ComputeOptionDefinitions.Zone); + command.Options.Add(ComputeOptionDefinitions.OsDiskSizeGb); + command.Options.Add(ComputeOptionDefinitions.OsDiskType); + + // Resource group is required for create + command.Validators.Add(commandResult => + { + var resourceGroup = commandResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); + if (string.IsNullOrEmpty(resourceGroup)) + { + commandResult.AddError($"Missing Required option: {OptionDefinitions.Common.ResourceGroup.Name}"); + } + }); + } + + protected override VmCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.VmName = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmName.Name); + options.Location = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Location.Name); + options.AdminUsername = parseResult.GetValueOrDefault(ComputeOptionDefinitions.AdminUsername.Name); + options.AdminPassword = parseResult.GetValueOrDefault(ComputeOptionDefinitions.AdminPassword.Name); + options.SshPublicKey = parseResult.GetValueOrDefault(ComputeOptionDefinitions.SshPublicKey.Name); + options.VmSize = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmSize.Name); + options.Image = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Image.Name); + options.Workload = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Workload.Name); + options.OsType = parseResult.GetValueOrDefault(ComputeOptionDefinitions.OsType.Name); + options.VirtualNetwork = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VirtualNetwork.Name); + options.Subnet = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Subnet.Name); + options.PublicIpAddress = parseResult.GetValueOrDefault(ComputeOptionDefinitions.PublicIpAddress.Name); + options.NetworkSecurityGroup = parseResult.GetValueOrDefault(ComputeOptionDefinitions.NetworkSecurityGroup.Name); + options.NoPublicIp = parseResult.GetValueOrDefault(ComputeOptionDefinitions.NoPublicIp.Name); + options.Zone = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Zone.Name); + options.OsDiskSizeGb = parseResult.GetValueOrDefault(ComputeOptionDefinitions.OsDiskSizeGb.Name); + options.OsDiskType = parseResult.GetValueOrDefault(ComputeOptionDefinitions.OsDiskType.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + // Determine OS type from image + var effectiveOsType = ComputeUtilities.DetermineOsType(options.OsType, options.Image); + + // Custom validation: For Windows VMs, password is required + if (effectiveOsType.Equals("windows", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(options.AdminPassword)) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = "The --admin-password option is required for Windows VMs."; + return context.Response; + } + + // Custom validation: For Windows VMs, computer name cannot exceed 15 characters + if (effectiveOsType.Equals("windows", StringComparison.OrdinalIgnoreCase) && options.VmName!.Length > 15) + { + throw new CommandValidationException( + VmRequirements.WindowsComputerName, + HttpStatusCode.BadRequest); + } + + // Custom validation: For Linux VMs, either SSH key or password must be provided + if (effectiveOsType.Equals("linux", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(options.SshPublicKey) && + string.IsNullOrEmpty(options.AdminPassword)) + { + throw new CommandValidationException( + "Linux VMs require authentication. Please provide either --ssh-public-key or --admin-password. " + + "To use SSH, first read the user's public key file (e.g., ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub) " + + "and pass the full key content to --ssh-public-key.", + HttpStatusCode.BadRequest); + } + + var computeService = context.GetService(); + + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + var result = await computeService.CreateVmAsync( + options.VmName!, + options.ResourceGroup!, + options.Subscription!, + options.Location!, + options.AdminUsername!, + options.VmSize, + options.Image, + options.AdminPassword, + options.SshPublicKey, + options.Workload, + options.OsType, + options.VirtualNetwork, + options.Subnet, + options.PublicIpAddress, + options.NetworkSecurityGroup, + options.NoPublicIp, + options.Zone, + options.OsDiskSizeGb, + options.OsDiskType, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new VmCreateCommandResult(result), + ComputeJsonContext.Default.VmCreateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating VM. VmName: {VmName}, ResourceGroup: {ResourceGroup}, Location: {Location}, Subscription: {Subscription}, Workload: {Workload}", + options.VmName, options.ResourceGroup, options.Location, options.Subscription, options.Workload); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Resource not found. Verify the resource group exists and you have access.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed. Verify you have appropriate permissions to create VMs. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + $"A VM with the specified name already exists. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Message.Contains("quota", StringComparison.OrdinalIgnoreCase) => + $"Quota exceeded. You may need to request a quota increase for the selected VM size. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + internal record VmCreateCommandResult(VmCreateResult Vm); +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/Vm/VmUpdateCommand.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vm/VmUpdateCommand.cs new file mode 100644 index 0000000000..00b1734cba --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vm/VmUpdateCommand.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Options; +using Azure.Mcp.Tools.Compute.Options.Vm; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.Compute.Commands.Vm; + +public sealed class VmUpdateCommand(ILogger logger) + : BaseComputeCommand() +{ + private const string CommandTitle = "Update Virtual Machine"; + private readonly ILogger _logger = logger; + + public override string Id => "g7f2e5h0-8i6d-7e1h-2f4g-3h5i6j7k8l9m"; + + public override string Name => "update"; + + public override string Description => + """ + Update, modify, or change an existing Azure Virtual Machine (VM) configuration. + Enable or disable features, resize VM, add or remove tags, and configure settings. + Uses PATCH semantics - only specified properties are updated. + + Updatable properties: + - --vm-size: Resize the VM, change SKU size (requires VM to be deallocated for most size changes) + - --tags: Add, update, or remove tags in key=value,key2=value2 format + - --license-type: Enable or disable Azure Hybrid Benefit license + - --boot-diagnostics: Enable or disable boot diagnostics ('true' or 'false') + - --user-data: Update base64-encoded user data + + Required options: + - --vm-name: Name of the VM to update + - --resource-group: Resource group name + - --subscription: Subscription ID or name + + At least one update property must be specified. + + Examples: + - Add tags: --tags environment=prod,team=compute + - Enable Hybrid Benefit: --license-type Windows_Server + - Disable Hybrid Benefit: --license-type None + - Enable boot diagnostics: --boot-diagnostics true + """; + + 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); + + // Required options + command.Options.Add(ComputeOptionDefinitions.VmName.AsRequired()); + + // Update options (at least one required - validated in command) + command.Options.Add(ComputeOptionDefinitions.VmSize); + command.Options.Add(ComputeOptionDefinitions.Tags); + command.Options.Add(ComputeOptionDefinitions.LicenseType); + command.Options.Add(ComputeOptionDefinitions.BootDiagnostics); + command.Options.Add(ComputeOptionDefinitions.UserData); + + // Resource group is required for update + command.Validators.Add(commandResult => + { + var resourceGroup = commandResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); + if (string.IsNullOrEmpty(resourceGroup)) + { + commandResult.AddError($"Missing Required option: {OptionDefinitions.Common.ResourceGroup.Name}"); + } + }); + } + + protected override VmUpdateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.VmName = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmName.Name); + options.VmSize = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmSize.Name); + options.Tags = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Tags.Name); + options.LicenseType = parseResult.GetValueOrDefault(ComputeOptionDefinitions.LicenseType.Name); + options.BootDiagnostics = parseResult.GetValueOrDefault(ComputeOptionDefinitions.BootDiagnostics.Name); + options.UserData = parseResult.GetValueOrDefault(ComputeOptionDefinitions.UserData.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + // Custom validation: At least one update property must be specified + if (string.IsNullOrEmpty(options.VmSize) && + string.IsNullOrEmpty(options.Tags) && + string.IsNullOrEmpty(options.LicenseType) && + string.IsNullOrEmpty(options.BootDiagnostics) && + string.IsNullOrEmpty(options.UserData)) + { + throw new CommandValidationException( + "At least one update property must be specified: --vm-size, --tags, --license-type, --boot-diagnostics, or --user-data.", + HttpStatusCode.BadRequest); + } + + var computeService = context.GetService(); + + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + var result = await computeService.UpdateVmAsync( + options.VmName!, + options.ResourceGroup!, + options.Subscription!, + options.VmSize, + options.Tags, + options.LicenseType, + options.BootDiagnostics, + options.UserData, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new VmUpdateCommandResult(result), + ComputeJsonContext.Default.VmUpdateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error updating VM. VmName: {VmName}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.VmName, options.ResourceGroup, options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "VM not found. Verify the VM name, resource group, and that you have access.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed. Verify you have appropriate permissions to update VM. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + $"Operation conflict. The VM may need to be deallocated for size changes. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Message.Contains("quota", StringComparison.OrdinalIgnoreCase) => + $"Quota exceeded. You may need to request a quota increase for the selected VM size. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + internal record VmUpdateCommandResult(VmUpdateResult Vm); +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/Vmss/VmssCreateCommand.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vmss/VmssCreateCommand.cs new file mode 100644 index 0000000000..23fc86814f --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vmss/VmssCreateCommand.cs @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Options; +using Azure.Mcp.Tools.Compute.Options.Vmss; +using Azure.Mcp.Tools.Compute.Services; +using Azure.Mcp.Tools.Compute.Utilities; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.Compute.Commands.Vmss; + +public sealed class VmssCreateCommand(ILogger logger) + : BaseComputeCommand() +{ + private const string CommandTitle = "Create Virtual Machine Scale Set"; + private readonly ILogger _logger = logger; + + public override string Id => "e5d0c3f8-6g4b-5c9f-0d2e-1f3g4h5i6j7k"; + + public override string Name => "create"; + + public override string Description => + """ + Create an Azure Virtual Machine Scale Set (VMSS) with smart defaults based on workload requirements. + Supports automatic VM size selection based on workload type (development, web, database, compute, memory, gpu, general). + Creates necessary network resources (VNet, subnet) if not specified. + Supports both Linux and Windows with SSH key or password authentication. + + Workload types and suggested configurations: + - development: Standard_B2s - Cost-effective burstable VM for dev/test + - web: Standard_D2s_v3 - General purpose for web servers + - database: Standard_E4s_v3 - Memory-optimized for databases + - compute: Standard_F4s_v2 - CPU-optimized for batch processing + - memory: Standard_E8s_v3 - High-memory for caching + - gpu: Standard_NC6s_v3 - GPU-enabled for ML/rendering + - general: Standard_D2s_v3 - Balanced general purpose + + Required options: + - --vmss-name: Name of the VMSS to create + - --resource-group: Resource group name + - --subscription: Subscription ID or name + - --location: Azure region + - --admin-username: Admin username + + Authentication requirements: + - For Windows VMSS: --admin-password is required + - For Linux VMSS: Either --ssh-public-key OR --admin-password is required + + IMPORTANT for Linux VMSS with SSH authentication: + Before calling this tool, you must first read the user's SSH public key file (typically ~/.ssh/id_rsa.pub, + ~/.ssh/id_ed25519.pub, or similar) and pass the full key content to --ssh-public-key. + The SSH public key is safe to share - it contains no secrets. + Example: --ssh-public-key "ssh-ed25519 AAAAC3... user@host" + + Optional: + - --instance-count: Number of VM instances (default: 2) + - --upgrade-policy: Upgrade policy mode: 'Automatic', 'Manual', or 'Rolling' (default: 'Manual') + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = true, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + LocalRequired = false, + Secret = true + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + + // Required options + command.Options.Add(ComputeOptionDefinitions.VmssName.AsRequired()); + command.Options.Add(ComputeOptionDefinitions.Location.AsRequired()); + command.Options.Add(ComputeOptionDefinitions.AdminUsername.AsRequired()); + + // Authentication options (at least one required - validated in command) + command.Options.Add(ComputeOptionDefinitions.AdminPassword); + command.Options.Add(ComputeOptionDefinitions.SshPublicKey); + + // Optional configuration + command.Options.Add(ComputeOptionDefinitions.VmSize); + command.Options.Add(ComputeOptionDefinitions.Image); + command.Options.Add(ComputeOptionDefinitions.Workload); + command.Options.Add(ComputeOptionDefinitions.OsType); + + // VMSS-specific options + command.Options.Add(ComputeOptionDefinitions.InstanceCount); + command.Options.Add(ComputeOptionDefinitions.UpgradePolicy); + + // Network options + command.Options.Add(ComputeOptionDefinitions.VirtualNetwork); + command.Options.Add(ComputeOptionDefinitions.Subnet); + + // Additional options + command.Options.Add(ComputeOptionDefinitions.Zone); + command.Options.Add(ComputeOptionDefinitions.OsDiskSizeGb); + command.Options.Add(ComputeOptionDefinitions.OsDiskType); + + // Resource group is required for create + command.Validators.Add(commandResult => + { + var resourceGroup = commandResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); + if (string.IsNullOrEmpty(resourceGroup)) + { + commandResult.AddError($"Missing Required option: {OptionDefinitions.Common.ResourceGroup.Name}"); + } + }); + } + + protected override VmssCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.VmssName = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmssName.Name); + options.Location = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Location.Name); + options.AdminUsername = parseResult.GetValueOrDefault(ComputeOptionDefinitions.AdminUsername.Name); + options.AdminPassword = parseResult.GetValueOrDefault(ComputeOptionDefinitions.AdminPassword.Name); + options.SshPublicKey = parseResult.GetValueOrDefault(ComputeOptionDefinitions.SshPublicKey.Name); + options.VmSize = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmSize.Name); + options.Image = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Image.Name); + options.Workload = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Workload.Name); + options.OsType = parseResult.GetValueOrDefault(ComputeOptionDefinitions.OsType.Name); + options.InstanceCount = parseResult.GetValueOrDefault(ComputeOptionDefinitions.InstanceCount.Name); + options.UpgradePolicy = parseResult.GetValueOrDefault(ComputeOptionDefinitions.UpgradePolicy.Name); + options.VirtualNetwork = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VirtualNetwork.Name); + options.Subnet = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Subnet.Name); + options.Zone = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Zone.Name); + options.OsDiskSizeGb = parseResult.GetValueOrDefault(ComputeOptionDefinitions.OsDiskSizeGb.Name); + options.OsDiskType = parseResult.GetValueOrDefault(ComputeOptionDefinitions.OsDiskType.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + // Determine OS type from image + var effectiveOsType = ComputeUtilities.DetermineOsType(options.OsType, options.Image); + + // Custom validation: For Windows VMSS, password is required + if (effectiveOsType.Equals("windows", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(options.AdminPassword)) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = "The --admin-password option is required for Windows VMSS."; + return context.Response; + } + + // Custom validation: For Windows VMSS, name cannot exceed 9 characters (Azure adds 6-char suffix for computer name) + if (effectiveOsType.Equals("windows", StringComparison.OrdinalIgnoreCase) && options.VmssName!.Length > 9) + { + throw new CommandValidationException( + "Windows VMSS name cannot exceed 9 characters. Azure appends a 6-character suffix to create the computer name, and Windows computer names are limited to 15 characters total.", + HttpStatusCode.BadRequest); + } + + // Custom validation: For Linux VMSS, either SSH key or password must be provided + if (effectiveOsType.Equals("linux", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(options.SshPublicKey) && + string.IsNullOrEmpty(options.AdminPassword)) + { + throw new CommandValidationException( + "Linux VMSS require authentication. Please provide either --ssh-public-key or --admin-password. " + + "To use SSH, first read the user's public key file (e.g., ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub) " + + "and pass the full key content to --ssh-public-key.", + HttpStatusCode.BadRequest); + } + + var computeService = context.GetService(); + + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + var result = await computeService.CreateVmssAsync( + options.VmssName!, + options.ResourceGroup!, + options.Subscription!, + options.Location!, + options.AdminUsername!, + options.VmSize, + options.Image, + options.AdminPassword, + options.SshPublicKey, + options.Workload, + options.OsType, + options.VirtualNetwork, + options.Subnet, + options.InstanceCount, + options.UpgradePolicy, + options.Zone, + options.OsDiskSizeGb, + options.OsDiskType, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new VmssCreateCommandResult(result), + ComputeJsonContext.Default.VmssCreateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating VMSS. VmssName: {VmssName}, ResourceGroup: {ResourceGroup}, Location: {Location}, Subscription: {Subscription}, Workload: {Workload}", + options.VmssName, options.ResourceGroup, options.Location, options.Subscription, options.Workload); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Resource not found. Verify the resource group exists and you have access.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed. Verify you have appropriate permissions to create VMSS. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + $"A VMSS with the specified name already exists. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Message.Contains("quota", StringComparison.OrdinalIgnoreCase) => + $"Quota exceeded. You may need to request a quota increase for the selected VM size. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + internal record VmssCreateCommandResult(VmssCreateResult Vmss); +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/Vmss/VmssUpdateCommand.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vmss/VmssUpdateCommand.cs new file mode 100644 index 0000000000..8b38cd441d --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/Vmss/VmssUpdateCommand.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Options; +using Azure.Mcp.Tools.Compute.Options.Vmss; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.Compute.Commands.Vmss; + +public sealed class VmssUpdateCommand(ILogger logger) + : BaseComputeCommand() +{ + private const string CommandTitle = "Update Virtual Machine Scale Set"; + private readonly ILogger _logger = logger; + + public override string Id => "f6e1d4g9-7h5c-6d0g-1e3f-2g4h5i6j7k8l"; + + public override string Name => "update"; + + public override string Description => + """ + Update, modify, scale, or change an existing Azure Virtual Machine Scale Set (VMSS) configuration. + Scale out or scale in instances, enable or disable features, add or remove tags, and configure upgrade policies. + Uses PATCH semantics - only specified properties are updated. + + Updatable properties: + - --upgrade-policy: Change upgrade policy mode (Automatic, Manual, Rolling) + - --capacity: Scale out or scale in - change the number of VM instances + - --vm-size: Resize VMs, change the VM SKU size + - --overprovision: Enable or disable overprovisioning + - --enable-auto-os-upgrade: Enable or disable automatic OS image upgrades + - --scale-in-policy: Set scale-in policy (Default, OldestVM, NewestVM) + - --tags: Add, update, or remove tags in key=value,key2=value2 format + + Required options: + - --vmss-name: Name of the VMSS to update + - --resource-group: Resource group name + - --subscription: Subscription ID or name + + At least one update property must be specified. + + Examples: + - Update upgrade policy: --upgrade-policy Automatic + - Scale out to 5 instances: --capacity 5 + - Scale in to 2 instances: --capacity 2 + - Resize VM size: --vm-size Standard_D4s_v3 + - Enable auto OS upgrade: --enable-auto-os-upgrade true + - Set scale-in policy: --scale-in-policy OldestVM + - Add tags: --tags environment=prod,team=compute + """; + + 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); + + // Required options + command.Options.Add(ComputeOptionDefinitions.VmssName.AsRequired()); + + // Update options (at least one required - validated in command) + command.Options.Add(ComputeOptionDefinitions.UpgradePolicy); + command.Options.Add(ComputeOptionDefinitions.Capacity); + command.Options.Add(ComputeOptionDefinitions.VmSize); + command.Options.Add(ComputeOptionDefinitions.Overprovision); + command.Options.Add(ComputeOptionDefinitions.EnableAutoOsUpgrade); + command.Options.Add(ComputeOptionDefinitions.ScaleInPolicy); + command.Options.Add(ComputeOptionDefinitions.Tags); + + // Resource group is required for update + command.Validators.Add(commandResult => + { + var resourceGroup = commandResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); + if (string.IsNullOrEmpty(resourceGroup)) + { + commandResult.AddError($"Missing Required option: {OptionDefinitions.Common.ResourceGroup.Name}"); + } + }); + } + + protected override VmssUpdateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.VmssName = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmssName.Name); + options.UpgradePolicy = parseResult.GetValueOrDefault(ComputeOptionDefinitions.UpgradePolicy.Name); + options.Capacity = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Capacity.Name); + options.VmSize = parseResult.GetValueOrDefault(ComputeOptionDefinitions.VmSize.Name); + options.Overprovision = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Overprovision.Name); + options.EnableAutoOsUpgrade = parseResult.GetValueOrDefault(ComputeOptionDefinitions.EnableAutoOsUpgrade.Name); + options.ScaleInPolicy = parseResult.GetValueOrDefault(ComputeOptionDefinitions.ScaleInPolicy.Name); + options.Tags = parseResult.GetValueOrDefault(ComputeOptionDefinitions.Tags.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + // Custom validation: At least one update property must be specified + if (string.IsNullOrEmpty(options.UpgradePolicy) && + !options.Capacity.HasValue && + string.IsNullOrEmpty(options.VmSize) && + !options.Overprovision.HasValue && + !options.EnableAutoOsUpgrade.HasValue && + string.IsNullOrEmpty(options.ScaleInPolicy) && + string.IsNullOrEmpty(options.Tags)) + { + throw new CommandValidationException( + "At least one update property must be specified: --upgrade-policy, --capacity, --vm-size, --overprovision, --enable-auto-os-upgrade, --scale-in-policy, or --tags.", + HttpStatusCode.BadRequest); + } + + var computeService = context.GetService(); + + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + var result = await computeService.UpdateVmssAsync( + options.VmssName!, + options.ResourceGroup!, + options.Subscription!, + options.VmSize, + options.Capacity, + options.UpgradePolicy, + options.Overprovision, + options.EnableAutoOsUpgrade, + options.ScaleInPolicy, + options.Tags, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new VmssUpdateCommandResult(result), + ComputeJsonContext.Default.VmssUpdateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error updating VMSS. VmssName: {VmssName}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.VmssName, options.ResourceGroup, options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "VMSS not found. Verify the VMSS name, resource group, and that you have access.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed. Verify you have appropriate permissions to update VMSS. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Message.Contains("quota", StringComparison.OrdinalIgnoreCase) => + $"Quota exceeded. You may need to request a quota increase for the selected VM size or capacity. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + internal record VmssUpdateCommandResult(VmssUpdateResult Vmss); +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs b/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs index f412b84b02..d79e3b7ae6 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs @@ -26,9 +26,13 @@ public void ConfigureServices(IServiceCollection services) // VM commands services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // VMSS commands services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Disk commands services.AddSingleton(); @@ -40,7 +44,8 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) """ Compute operations - Commands for managing and monitoring Azure Virtual Machines (VMs), Virtual Machine Scale Sets (VMSS), and Managed Disks. This tool provides comprehensive access to VM lifecycle management, instance monitoring, size discovery, and scale set operations. - Use this tool when you need to list, query, or monitor VMs and VMSS instances across subscriptions and resource groups. + Use this tool when you need to list, query, create, or monitor VMs and VMSS instances across subscriptions and resource groups. + Supports smart defaults for VM creation based on workload requirements (development, web, database, compute, memory, gpu, general). This tool is a hierarchical MCP command router where sub-commands are routed to MCP servers that require specific fields inside the "parameters" object. To invoke a command, set "command" and wrap its arguments in "parameters". Set "learn=true" to discover available sub-commands for different Azure Compute operations. @@ -49,13 +54,19 @@ Note that this tool requires appropriate Azure RBAC permissions and will only ac Title); // Create VM subgroup - var vm = new CommandGroup("vm", "Virtual Machine operations - Commands for managing and monitoring Azure Virtual Machines including lifecycle, status, and size information."); + var vm = new CommandGroup("vm", "Virtual Machine operations - Commands for managing and monitoring Azure Virtual Machines including lifecycle, status, creation with smart workload-based defaults, and size information."); compute.AddSubGroup(vm); // Register VM commands var vmGet = serviceProvider.GetRequiredService(); vm.AddCommand(vmGet.Name, vmGet); + var vmCreate = serviceProvider.GetRequiredService(); + vm.AddCommand(vmCreate.Name, vmCreate); + + var vmUpdate = serviceProvider.GetRequiredService(); + vm.AddCommand(vmUpdate.Name, vmUpdate); + // Create VMSS subgroup var vmss = new CommandGroup("vmss", "Virtual Machine Scale Set operations - Commands for managing and monitoring Azure Virtual Machine Scale Sets including scale set details, instances, and rolling upgrades."); compute.AddSubGroup(vmss); @@ -64,6 +75,12 @@ Note that this tool requires appropriate Azure RBAC permissions and will only ac var vmssGet = serviceProvider.GetRequiredService(); vmss.AddCommand(vmssGet.Name, vmssGet); + var vmssCreate = serviceProvider.GetRequiredService(); + vmss.AddCommand(vmssCreate.Name, vmssCreate); + + var vmssUpdate = serviceProvider.GetRequiredService(); + vmss.AddCommand(vmssUpdate.Name, vmssUpdate); + // Create Disk subgroup var disk = new CommandGroup( "disk", diff --git a/tools/Azure.Mcp.Tools.Compute/src/Models/VmCreateResult.cs b/tools/Azure.Mcp.Tools.Compute/src/Models/VmCreateResult.cs new file mode 100644 index 0000000000..d5a3abaadd --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Models/VmCreateResult.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Compute.Models; + +public sealed record VmCreateResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("location")] string? Location, + [property: JsonPropertyName("vmSize")] string? VmSize, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState, + [property: JsonPropertyName("osType")] string? OsType, + [property: JsonPropertyName("publicIpAddress")] string? PublicIpAddress, + [property: JsonPropertyName("privateIpAddress")] string? PrivateIpAddress, + [property: JsonPropertyName("zones")] IReadOnlyList? Zones, + [property: JsonPropertyName("tags")] IReadOnlyDictionary? Tags, + [property: JsonPropertyName("workloadConfiguration")] WorkloadConfiguration? WorkloadConfiguration); + +public sealed record WorkloadConfiguration( + [property: JsonPropertyName("workloadType")] string WorkloadType, + [property: JsonPropertyName("suggestedVmSize")] string SuggestedVmSize, + [property: JsonPropertyName("suggestedOsDiskType")] string SuggestedOsDiskType, + [property: JsonPropertyName("suggestedOsDiskSizeGb")] int SuggestedOsDiskSizeGb, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("requirements")] string? Requirements = null); + +/// +/// Requirements for Windows VMs: +/// - Computer name cannot be more than 15 characters long +/// - Computer name cannot be entirely numeric +/// - Computer name cannot contain the following characters: ` ~ ! @ # $ % ^ & * ( ) = + _ [ ] { } \ | ; : . ' " , < > / ? +/// +public static class VmRequirements +{ + public const string WindowsComputerName = "Windows computer name cannot be more than 15 characters long, be entirely numeric, or contain special characters (` ~ ! @ # $ % ^ & * ( ) = + _ [ ] { } \\ | ; : . ' \" , < > / ?)."; +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Models/VmUpdateResult.cs b/tools/Azure.Mcp.Tools.Compute/src/Models/VmUpdateResult.cs new file mode 100644 index 0000000000..ce132d54ed --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Models/VmUpdateResult.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Compute.Models; + +public sealed record VmUpdateResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("location")] string? Location, + [property: JsonPropertyName("vmSize")] string? VmSize, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState, + [property: JsonPropertyName("powerState")] string? PowerState, + [property: JsonPropertyName("osType")] string? OsType, + [property: JsonPropertyName("licenseType")] string? LicenseType, + [property: JsonPropertyName("zones")] IReadOnlyList? Zones, + [property: JsonPropertyName("tags")] IReadOnlyDictionary? Tags); diff --git a/tools/Azure.Mcp.Tools.Compute/src/Models/VmssCreateResult.cs b/tools/Azure.Mcp.Tools.Compute/src/Models/VmssCreateResult.cs new file mode 100644 index 0000000000..0c9bacc49b --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Models/VmssCreateResult.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Compute.Models; + +public sealed record VmssCreateResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("location")] string? Location, + [property: JsonPropertyName("vmSize")] string? VmSize, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState, + [property: JsonPropertyName("osType")] string? OsType, + [property: JsonPropertyName("capacity")] int Capacity, + [property: JsonPropertyName("upgradePolicy")] string? UpgradePolicy, + [property: JsonPropertyName("zones")] IReadOnlyList? Zones, + [property: JsonPropertyName("tags")] IReadOnlyDictionary? Tags, + [property: JsonPropertyName("workloadConfiguration")] WorkloadConfiguration? WorkloadConfiguration); diff --git a/tools/Azure.Mcp.Tools.Compute/src/Models/VmssUpdateResult.cs b/tools/Azure.Mcp.Tools.Compute/src/Models/VmssUpdateResult.cs new file mode 100644 index 0000000000..fff54467df --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Models/VmssUpdateResult.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Compute.Models; + +public sealed record VmssUpdateResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("location")] string? Location, + [property: JsonPropertyName("vmSize")] string? VmSize, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState, + [property: JsonPropertyName("capacity")] int? Capacity, + [property: JsonPropertyName("upgradePolicy")] string? UpgradePolicy, + [property: JsonPropertyName("zones")] IReadOnlyList? Zones, + [property: JsonPropertyName("tags")] IReadOnlyDictionary? Tags); diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDefinitions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDefinitions.cs index e0767deb7f..f6f5840542 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDefinitions.cs @@ -10,6 +10,21 @@ public static class ComputeOptionDefinitions public const string InstanceIdName = "instance-id"; public const string LocationName = "location"; public const string DiskName = "disk"; + public const string VmSizeName = "vm-size"; + public const string ImageName = "image"; + public const string AdminUsernameName = "admin-username"; + public const string AdminPasswordName = "admin-password"; + public const string SshPublicKeyName = "ssh-public-key"; + public const string WorkloadName = "workload"; + public const string OsTypeName = "os-type"; + public const string VirtualNetworkName = "virtual-network"; + public const string SubnetName = "subnet"; + public const string PublicIpAddressName = "public-ip-address"; + public const string NetworkSecurityGroupName = "network-security-group"; + public const string NoPublicIpName = "no-public-ip"; + public const string ZoneName = "zone"; + public const string OsDiskSizeGbName = "os-disk-size-gb"; + public const string OsDiskTypeName = "os-disk-type"; public static readonly Option Disk = new($"--{DiskName}", "--name") { @@ -46,4 +61,171 @@ public static class ComputeOptionDefinitions Description = "The Azure region/location", Required = true }; + + public static readonly Option VmSize = new($"--{VmSizeName}", "--size") + { + Description = "The VM size (e.g., Standard_D2s_v3, Standard_B2s). If not specified, will be determined based on workload", + Required = false + }; + + public static readonly Option Image = new($"--{ImageName}") + { + Description = "The OS image to use. Can be URN (publisher:offer:sku:version) or alias like 'Ubuntu2404', 'Win2022Datacenter'. Defaults to Ubuntu 24.04 LTS", + Required = false + }; + + public static readonly Option AdminUsername = new($"--{AdminUsernameName}") + { + Description = "The admin username for the VM. Required for VM creation", + Required = false + }; + + public static readonly Option AdminPassword = new($"--{AdminPasswordName}") + { + Description = "The admin password for Windows VMs or when SSH key is not provided for Linux VMs", + Required = false + }; + + public static readonly Option SshPublicKey = new($"--{SshPublicKeyName}") + { + Description = "SSH public key for Linux VMs. Can be the key content or path to a file", + Required = false + }; + + public static readonly Option Workload = new($"--{WorkloadName}", "-w") + { + Description = "The type of workload to run. Used to suggest appropriate VM size and configuration. Options: development, web, database, compute, memory, gpu, general", + Required = false + }; + + public static readonly Option OsType = new($"--{OsTypeName}") + { + Description = "The operating system type: 'linux' or 'windows'. Defaults to 'linux'", + Required = false + }; + + public static readonly Option VirtualNetwork = new($"--{VirtualNetworkName}", "--vnet") + { + Description = "Name of an existing virtual network to use. If not specified, a new one will be created", + Required = false + }; + + public static readonly Option Subnet = new($"--{SubnetName}") + { + Description = "Name of the subnet within the virtual network", + Required = false + }; + + public static readonly Option PublicIpAddress = new($"--{PublicIpAddressName}") + { + Description = "Name of the public IP address to use or create", + Required = false + }; + + public static readonly Option NetworkSecurityGroup = new($"--{NetworkSecurityGroupName}", "--nsg") + { + Description = "Name of the network security group to use or create", + Required = false + }; + + public static readonly Option NoPublicIp = new($"--{NoPublicIpName}") + { + Description = "Do not create or assign a public IP address", + Required = false + }; + + public static readonly Option Zone = new($"--{ZoneName}", "-z") + { + Description = "Availability zone for the VM (e.g., '1', '2', '3')", + Required = false + }; + + public static readonly Option OsDiskSizeGb = new($"--{OsDiskSizeGbName}") + { + Description = "OS disk size in GB. Defaults based on image requirements", + Required = false + }; + + public static readonly Option OsDiskType = new($"--{OsDiskTypeName}") + { + Description = "OS disk type: 'Premium_LRS', 'StandardSSD_LRS', 'Standard_LRS'. Defaults based on VM size", + Required = false + }; + + // VMSS-specific options + public const string InstanceCountName = "instance-count"; + public const string UpgradePolicyName = "upgrade-policy"; + + public static readonly Option InstanceCount = new($"--{InstanceCountName}") + { + Description = "Number of VM instances in the scale set. Default is 2", + Required = false + }; + + public static readonly Option UpgradePolicy = new($"--{UpgradePolicyName}") + { + Description = "Upgrade policy mode: 'Automatic', 'Manual', or 'Rolling'. Default is 'Manual'", + Required = false + }; + + public const string CapacityName = "capacity"; + + public static readonly Option Capacity = new($"--{CapacityName}") + { + Description = "Number of VM instances (capacity) in the scale set", + Required = false + }; + + // Additional VMSS update options + public const string OverprovisionName = "overprovision"; + public const string EnableAutoOsUpgradeName = "enable-auto-os-upgrade"; + public const string ScaleInPolicyName = "scale-in-policy"; + public const string TagsName = "tags"; + + public static readonly Option Overprovision = new($"--{OverprovisionName}") + { + Description = "Enable or disable overprovisioning. When enabled, Azure provisions more VMs than requested and deletes extra VMs after deployment", + Required = false + }; + + public static readonly Option EnableAutoOsUpgrade = new($"--{EnableAutoOsUpgradeName}") + { + Description = "Enable automatic OS image upgrades. Requires health probes or Application Health extension", + Required = false + }; + + public static readonly Option ScaleInPolicy = new($"--{ScaleInPolicyName}") + { + Description = "Scale-in policy to determine which VMs to remove: 'Default', 'NewestVM', or 'OldestVM'", + Required = false + }; + + public static readonly Option Tags = new($"--{TagsName}") + { + Description = "Resource tags in format 'key1=value1,key2=value2'. Use empty string to clear all tags", + Required = false + }; + + // VM update options + public const string LicenseTypeName = "license-type"; + public const string BootDiagnosticsName = "boot-diagnostics"; + public const string UserDataName = "user-data"; + + public static readonly Option LicenseType = new($"--{LicenseTypeName}") + { + Description = "License type for Azure Hybrid Benefit: 'Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS', or 'None' to disable", + Required = false + }; + + public static readonly Option BootDiagnostics = new($"--{BootDiagnosticsName}") + { + Description = "Enable or disable boot diagnostics: 'true' or 'false'", + Required = false + }; + + public static readonly Option UserData = new($"--{UserDataName}") + { + Description = "Base64-encoded user data for the VM. Use to update custom data scripts", + Required = false + }; } diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/Vm/VmCreateOptions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/Vm/VmCreateOptions.cs new file mode 100644 index 0000000000..7ca5ce194f --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/Vm/VmCreateOptions.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Compute.Options.Vm; + +public class VmCreateOptions : BaseComputeOptions +{ + public string? VmName { get; set; } + + public string? Location { get; set; } + + public string? VmSize { get; set; } + + public string? Image { get; set; } + + public string? AdminUsername { get; set; } + + public string? AdminPassword { get; set; } + + public string? SshPublicKey { get; set; } + + public string? Workload { get; set; } + + public string? OsType { get; set; } + + public string? VirtualNetwork { get; set; } + + public string? Subnet { get; set; } + + public string? PublicIpAddress { get; set; } + + public string? NetworkSecurityGroup { get; set; } + + public bool? NoPublicIp { get; set; } + + public string? Zone { get; set; } + + public int? OsDiskSizeGb { get; set; } + + public string? OsDiskType { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/Vm/VmUpdateOptions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/Vm/VmUpdateOptions.cs new file mode 100644 index 0000000000..05934abab7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/Vm/VmUpdateOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Compute.Options.Vm; + +public class VmUpdateOptions : BaseComputeOptions +{ + public string? VmName { get; set; } + + public string? VmSize { get; set; } + + public string? Tags { get; set; } + + public string? LicenseType { get; set; } + + public string? BootDiagnostics { get; set; } + + public string? UserData { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/Vmss/VmssCreateOptions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/Vmss/VmssCreateOptions.cs new file mode 100644 index 0000000000..7209dac85a --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/Vmss/VmssCreateOptions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Compute.Options.Vmss; + +public class VmssCreateOptions : BaseComputeOptions +{ + public string? VmssName { get; set; } + + public string? Location { get; set; } + + public string? VmSize { get; set; } + + public string? Image { get; set; } + + public string? AdminUsername { get; set; } + + public string? AdminPassword { get; set; } + + public string? SshPublicKey { get; set; } + + public string? Workload { get; set; } + + public string? OsType { get; set; } + + public string? VirtualNetwork { get; set; } + + public string? Subnet { get; set; } + + public int? InstanceCount { get; set; } + + public string? UpgradePolicy { get; set; } + + public string? Zone { get; set; } + + public int? OsDiskSizeGb { get; set; } + + public string? OsDiskType { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/Vmss/VmssUpdateOptions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/Vmss/VmssUpdateOptions.cs new file mode 100644 index 0000000000..e779e86740 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/Vmss/VmssUpdateOptions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Compute.Options.Vmss; + +public class VmssUpdateOptions : BaseComputeOptions +{ + public string? VmssName { get; set; } + + public string? VmSize { get; set; } + + public int? Capacity { get; set; } + + public string? UpgradePolicy { get; set; } + + public bool? Overprovision { get; set; } + + public bool? EnableAutoOsUpgrade { get; set; } + + public string? ScaleInPolicy { get; set; } + + public string? Tags { 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 d6ebb00827..67584c19fe 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Services/ComputeService.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Services/ComputeService.cs @@ -7,9 +7,12 @@ using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Utilities; using Azure.ResourceManager; using Azure.ResourceManager.Compute; using Azure.ResourceManager.Compute.Models; +using Azure.ResourceManager.Network; +using Azure.ResourceManager.Network.Models; using Azure.ResourceManager.Resources; using Microsoft.Extensions.Logging; @@ -23,6 +26,501 @@ public class ComputeService( { private readonly ILogger _logger = logger; + private static readonly Dictionary s_workloadConfigurations = new(StringComparer.OrdinalIgnoreCase) + { + ["development"] = new WorkloadConfiguration( + WorkloadType: "development", + SuggestedVmSize: "Standard_B2s", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 64, + Description: "Cost-effective burstable VM for development and testing workloads", + Requirements: VmRequirements.WindowsComputerName), + ["web"] = new WorkloadConfiguration( + WorkloadType: "web", + SuggestedVmSize: "Standard_D2s_v5", + SuggestedOsDiskType: "Premium_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM optimized for web servers and small to medium applications", + Requirements: VmRequirements.WindowsComputerName), + ["database"] = new WorkloadConfiguration( + WorkloadType: "database", + SuggestedVmSize: "Standard_E4s_v5", + SuggestedOsDiskType: "Premium_LRS", + SuggestedOsDiskSizeGb: 256, + Description: "Memory-optimized VM for database workloads with high memory-to-CPU ratio", + Requirements: VmRequirements.WindowsComputerName), + ["compute"] = new WorkloadConfiguration( + WorkloadType: "compute", + SuggestedVmSize: "Standard_F4s_v2", + SuggestedOsDiskType: "Premium_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "Compute-optimized VM for CPU-intensive workloads like batch processing and analytics", + Requirements: VmRequirements.WindowsComputerName), + ["memory"] = new WorkloadConfiguration( + WorkloadType: "memory", + SuggestedVmSize: "Standard_E8s_v5", + SuggestedOsDiskType: "Premium_LRS", + SuggestedOsDiskSizeGb: 256, + Description: "High-memory VM for in-memory databases, caching, and memory-intensive applications", + Requirements: VmRequirements.WindowsComputerName), + ["gpu"] = new WorkloadConfiguration( + WorkloadType: "gpu", + SuggestedVmSize: "Standard_NC6s_v3", + SuggestedOsDiskType: "Premium_LRS", + SuggestedOsDiskSizeGb: 256, + Description: "GPU-enabled VM for machine learning, rendering, and GPU-accelerated workloads", + Requirements: VmRequirements.WindowsComputerName), + ["general"] = new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D4s_v5", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage", + Requirements: VmRequirements.WindowsComputerName) + }; + + private static readonly Dictionary s_imageAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["Ubuntu2404"] = ("Canonical", "ubuntu-24_04-lts", "server", "latest"), + ["Ubuntu2204"] = ("Canonical", "0001-com-ubuntu-server-jammy", "22_04-lts-gen2", "latest"), + ["Ubuntu2004"] = ("Canonical", "0001-com-ubuntu-server-focal", "20_04-lts-gen2", "latest"), + ["Debian11"] = ("Debian", "debian-11", "11-gen2", "latest"), + ["Debian12"] = ("Debian", "debian-12", "12-gen2", "latest"), + ["RHEL9"] = ("RedHat", "RHEL", "9_0", "latest"), + ["CentOS8"] = ("OpenLogic", "CentOS", "8_5-gen2", "latest"), + ["Win2022Datacenter"] = ("MicrosoftWindowsServer", "WindowsServer", "2022-datacenter-g2", "latest"), + ["Win2019Datacenter"] = ("MicrosoftWindowsServer", "WindowsServer", "2019-datacenter-gensecond", "latest"), + ["Win11Pro"] = ("MicrosoftWindowsDesktop", "windows-11", "win11-22h2-pro", "latest"), + ["Win10Pro"] = ("MicrosoftWindowsDesktop", "Windows-10", "win10-22h2-pro-g2", "latest") + }; + + public WorkloadConfiguration GetWorkloadConfiguration(string? workload) + { + if (string.IsNullOrEmpty(workload) || !s_workloadConfigurations.TryGetValue(workload, out var config)) + { + return s_workloadConfigurations["general"]; + } + return config; + } + + public async Task CreateVmAsync( + string vmName, + string resourceGroup, + string subscription, + string location, + string adminUsername, + string? vmSize = null, + string? image = null, + string? adminPassword = null, + string? sshPublicKey = null, + string? workload = null, + string? osType = null, + string? virtualNetwork = null, + string? subnet = null, + string? publicIpAddress = null, + string? networkSecurityGroup = null, + bool? noPublicIp = null, + string? zone = null, + int? osDiskSizeGb = null, + string? osDiskType = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + var armClient = await CreateArmClientAsync(tenant, retryPolicy, null, cancellationToken); + var subscriptionResource = armClient.GetSubscriptionResource( + SubscriptionResource.CreateResourceIdentifier(subscription)); + + var rgResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + var resourceGroupResource = rgResource.Value; + + // Get workload configuration + var workloadConfig = GetWorkloadConfiguration(workload); + + // Determine OS type + var effectiveOsType = ComputeUtilities.DetermineOsType(osType, image); + + // Determine VM size based on workload or explicit parameter + var effectiveVmSize = vmSize ?? workloadConfig.SuggestedVmSize; + + // Determine disk settings + var effectiveOsDiskType = osDiskType ?? workloadConfig.SuggestedOsDiskType; + // Only use explicit disk size if provided; otherwise let Azure use image's default size + var effectiveOsDiskSizeGb = osDiskSizeGb; + + // Parse image + var (publisher, offer, sku, version) = ParseImage(image); + + // Create or get network resources + var nicId = await CreateOrGetNetworkResourcesAsync( + resourceGroupResource, + vmName, + location, + virtualNetwork, + subnet, + publicIpAddress, + networkSecurityGroup, + noPublicIp ?? false, + effectiveOsType, + cancellationToken); + + // Build VM data + var vmData = new VirtualMachineData(new AzureLocation(location)) + { + HardwareProfile = new VirtualMachineHardwareProfile + { + VmSize = new VirtualMachineSizeType(effectiveVmSize) + }, + StorageProfile = new VirtualMachineStorageProfile + { + OSDisk = new VirtualMachineOSDisk(DiskCreateOptionType.FromImage) + { + Name = $"{vmName}-osdisk", + Caching = CachingType.ReadWrite, + ManagedDisk = new VirtualMachineManagedDisk + { + StorageAccountType = new StorageAccountType(effectiveOsDiskType) + }, + DiskSizeGB = effectiveOsDiskSizeGb + }, + ImageReference = new ImageReference + { + Publisher = publisher, + Offer = offer, + Sku = sku, + Version = version + } + }, + OSProfile = new VirtualMachineOSProfile + { + ComputerName = vmName, + AdminUsername = adminUsername + }, + NetworkProfile = new VirtualMachineNetworkProfile + { + NetworkInterfaces = + { + new VirtualMachineNetworkInterfaceReference + { + Id = nicId, + Primary = true + } + } + } + }; + + // Configure authentication based on OS type + if (effectiveOsType.Equals("windows", StringComparison.OrdinalIgnoreCase)) + { + vmData.OSProfile.AdminPassword = adminPassword; + vmData.OSProfile.WindowsConfiguration = new WindowsConfiguration + { + ProvisionVmAgent = true, + EnableAutomaticUpdates = true + }; + } + else + { + // For Linux VMs, configure SSH key if provided, otherwise allow Azure AD SSH login + vmData.OSProfile.LinuxConfiguration = new LinuxConfiguration + { + DisablePasswordAuthentication = string.IsNullOrEmpty(adminPassword) + }; + + // Only add SSH key if explicitly provided + if (!string.IsNullOrEmpty(sshPublicKey)) + { + // Check if it's a file path + var resolvedSshKey = File.Exists(sshPublicKey) + ? File.ReadAllText(sshPublicKey).Trim() + : sshPublicKey; + + vmData.OSProfile.LinuxConfiguration.SshPublicKeys.Add(new SshPublicKeyConfiguration + { + Path = $"/home/{adminUsername}/.ssh/authorized_keys", + KeyData = resolvedSshKey + }); + } + + if (!string.IsNullOrEmpty(adminPassword)) + { + vmData.OSProfile.AdminPassword = adminPassword; + vmData.OSProfile.LinuxConfiguration.DisablePasswordAuthentication = false; + } + + // Note: If neither SSH key nor password is provided, the VM will be created + // and can be accessed via Azure AD SSH login: az ssh vm --resource-group --vm-name + } + + // Add availability zone if specified + if (!string.IsNullOrEmpty(zone)) + { + vmData.Zones.Add(zone); + } + + // Create the VM + var vmCollection = resourceGroupResource.GetVirtualMachines(); + var vmOperation = await vmCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + vmName, + vmData, + cancellationToken); + + var createdVm = vmOperation.Value; + + // Get IP addresses + var (publicIp, privateIp) = await GetVmIpAddressesAsync( + resourceGroupResource, + nicId, + cancellationToken); + + return new VmCreateResult( + Name: createdVm.Data.Name, + Id: createdVm.Data.Id?.ToString(), + Location: createdVm.Data.Location.Name, + VmSize: createdVm.Data.HardwareProfile?.VmSize?.ToString(), + ProvisioningState: createdVm.Data.ProvisioningState, + OsType: effectiveOsType, + PublicIpAddress: publicIp, + PrivateIpAddress: privateIp, + Zones: createdVm.Data.Zones?.ToList(), + Tags: createdVm.Data.Tags as IReadOnlyDictionary, + WorkloadConfiguration: workloadConfig); + } + + private static (string Publisher, string Offer, string Sku, string Version) ParseImage(string? image) + { + // Default to Ubuntu 24.04 LTS + if (string.IsNullOrEmpty(image)) + { + return s_imageAliases["Ubuntu2404"]; + } + + // Check if it's an alias + if (s_imageAliases.TryGetValue(image, out var aliasConfig)) + { + return aliasConfig; + } + + // Try to parse as URN (publisher:offer:sku:version) + var parts = image.Split(':'); + if (parts.Length == 4) + { + return (parts[0], parts[1], parts[2], parts[3]); + } + + // Default fallback + return s_imageAliases["Ubuntu2404"]; + } + + private async Task CreateOrGetNetworkResourcesAsync( + ResourceGroupResource resourceGroup, + string vmName, + string location, + string? virtualNetwork, + string? subnet, + string? publicIpAddress, + string? networkSecurityGroup, + bool noPublicIp, + string osType, + CancellationToken cancellationToken) + { + var vnetName = virtualNetwork ?? $"{vmName}-vnet"; + var subnetName = subnet ?? "default"; + var nsgName = networkSecurityGroup ?? $"{vmName}-nsg"; + var nicName = $"{vmName}-nic"; + + // Create or get NSG + var nsgCollection = resourceGroup.GetNetworkSecurityGroups(); + NetworkSecurityGroupResource nsgResource; + + try + { + var existingNsg = await nsgCollection.GetAsync(nsgName, cancellationToken: cancellationToken); + nsgResource = existingNsg.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + var nsgData = new NetworkSecurityGroupData + { + Location = new AzureLocation(location) + }; + + // Add appropriate security rule based on OS type + // WARNING: These rules allow access from any source IP for quick-start scenarios. + // For production use, restrict SourceAddressPrefix to specific IP ranges. + var isWindows = osType.Equals("Windows", StringComparison.OrdinalIgnoreCase); + + if (isWindows) + { + _logger.LogWarning("Creating NSG with RDP (port 3389) open to all sources. For production, restrict the source IP range."); + nsgData.SecurityRules.Add(new SecurityRuleData + { + Name = "AllowRDP", + Priority = 1000, + Access = SecurityRuleAccess.Allow, + Direction = SecurityRuleDirection.Inbound, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "3389" + }); + } + else + { + _logger.LogWarning("Creating NSG with SSH (port 22) open to all sources. For production, restrict the source IP range."); + nsgData.SecurityRules.Add(new SecurityRuleData + { + Name = "AllowSSH", + Priority = 1000, + Access = SecurityRuleAccess.Allow, + Direction = SecurityRuleDirection.Inbound, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "22" + }); + } + + var nsgOperation = await nsgCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + nsgName, + nsgData, + cancellationToken); + nsgResource = nsgOperation.Value; + } + + // Create or get VNet + var vnetCollection = resourceGroup.GetVirtualNetworks(); + VirtualNetworkResource vnetResource; + + try + { + var existingVnet = await vnetCollection.GetAsync(vnetName, cancellationToken: cancellationToken); + vnetResource = existingVnet.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + var vnetData = new VirtualNetworkData + { + Location = new AzureLocation(location) + }; + vnetData.AddressPrefixes.Add("10.0.0.0/16"); + vnetData.Subnets.Add(new SubnetData + { + Name = subnetName, + AddressPrefix = "10.0.0.0/24", + NetworkSecurityGroup = new NetworkSecurityGroupData { Id = nsgResource.Id } + }); + + var vnetOperation = await vnetCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + vnetName, + vnetData, + cancellationToken); + vnetResource = vnetOperation.Value; + } + + // Get subnet + var subnetCollection = vnetResource.GetSubnets(); + var subnetResource = await subnetCollection.GetAsync(subnetName, cancellationToken: cancellationToken); + + // Create public IP if needed + PublicIPAddressResource? publicIpResource = null; + if (!noPublicIp) + { + var pipName = publicIpAddress ?? $"{vmName}-pip"; + var pipCollection = resourceGroup.GetPublicIPAddresses(); + + try + { + var existingPip = await pipCollection.GetAsync(pipName, cancellationToken: cancellationToken); + publicIpResource = existingPip.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + var pipData = new PublicIPAddressData + { + Location = new AzureLocation(location), + PublicIPAllocationMethod = NetworkIPAllocationMethod.Static, + Sku = new PublicIPAddressSku + { + Name = PublicIPAddressSkuName.Standard + } + }; + + var pipOperation = await pipCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + pipName, + pipData, + cancellationToken); + publicIpResource = pipOperation.Value; + } + } + + // Create NIC + var nicCollection = resourceGroup.GetNetworkInterfaces(); + var nicData = new NetworkInterfaceData + { + Location = new AzureLocation(location) + }; + + var ipConfig = new NetworkInterfaceIPConfigurationData + { + Name = "ipconfig1", + Primary = true, + PrivateIPAllocationMethod = NetworkIPAllocationMethod.Dynamic, + Subnet = new SubnetData { Id = subnetResource.Value.Id } + }; + + if (publicIpResource != null) + { + ipConfig.PublicIPAddress = new PublicIPAddressData { Id = publicIpResource.Id }; + } + + nicData.IPConfigurations.Add(ipConfig); + + var nicOperation = await nicCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + nicName, + nicData, + cancellationToken); + + return nicOperation.Value.Id; + } + + private static async Task<(string? PublicIp, string? PrivateIp)> GetVmIpAddressesAsync( + ResourceGroupResource resourceGroup, + ResourceIdentifier nicId, + CancellationToken cancellationToken) + { + var nicName = nicId.Name; + var nicCollection = resourceGroup.GetNetworkInterfaces(); + var nicResponse = await nicCollection.GetAsync(nicName, cancellationToken: cancellationToken); + var nic = nicResponse.Value; + + string? privateIp = null; + string? publicIp = null; + + foreach (var ipConfig in nic.Data.IPConfigurations) + { + privateIp ??= ipConfig.PrivateIPAddress; + + var publicIpId = ipConfig.PublicIPAddress?.Id; + if (publicIpId is not null) + { + var pipName = publicIpId.Name; + var pipCollection = resourceGroup.GetPublicIPAddresses(); + var pipResponse = await pipCollection.GetAsync(pipName, cancellationToken: cancellationToken); + publicIp = pipResponse.Value.Data.IPAddress; + } + } + + return (publicIp, privateIp); + } + public async Task GetVmAsync( string vmName, string resourceGroup, @@ -230,6 +728,505 @@ public async Task GetVmssVmAsync( return MapToVmssVmInfo(vmResource.Value.Data); } + public async Task CreateVmssAsync( + string vmssName, + string resourceGroup, + string subscription, + string location, + string adminUsername, + string? vmSize = null, + string? image = null, + string? adminPassword = null, + string? sshPublicKey = null, + string? workload = null, + string? osType = null, + string? virtualNetwork = null, + string? subnet = null, + int? instanceCount = null, + string? upgradePolicy = null, + string? zone = null, + int? osDiskSizeGb = null, + string? osDiskType = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + var armClient = await CreateArmClientAsync(tenant, retryPolicy, null, cancellationToken); + var subscriptionResource = armClient.GetSubscriptionResource( + SubscriptionResource.CreateResourceIdentifier(subscription)); + + var rgResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + var resourceGroupResource = rgResource.Value; + + // Get workload configuration + var workloadConfig = GetWorkloadConfiguration(workload); + + // Determine OS type + var effectiveOsType = ComputeUtilities.DetermineOsType(osType, image); + + // Determine VM size based on workload or explicit parameter + var effectiveVmSize = vmSize ?? workloadConfig.SuggestedVmSize; + + // Determine disk settings + var effectiveOsDiskType = osDiskType ?? workloadConfig.SuggestedOsDiskType; + var effectiveOsDiskSizeGb = osDiskSizeGb; + var effectiveInstanceCount = instanceCount ?? 2; + var effectiveUpgradePolicy = ParseUpgradePolicy(upgradePolicy); + + // Parse image + var (publisher, offer, sku, version) = ParseImage(image); + + // Create or get network resources for VMSS + var subnetId = await CreateOrGetVmssNetworkResourcesAsync( + resourceGroupResource, + vmssName, + location, + virtualNetwork, + subnet, + cancellationToken); + + // Build VMSS data using Flexible orchestration mode (default since Nov 2023) + var vmssData = new VirtualMachineScaleSetData(new AzureLocation(location)) + { + Sku = new ComputeSku + { + Name = effectiveVmSize, + Tier = "Standard", + Capacity = effectiveInstanceCount + }, + UpgradePolicy = new VirtualMachineScaleSetUpgradePolicy + { + Mode = effectiveUpgradePolicy + }, + Overprovision = false, + VirtualMachineProfile = new VirtualMachineScaleSetVmProfile + { + StorageProfile = new VirtualMachineScaleSetStorageProfile + { + OSDisk = new VirtualMachineScaleSetOSDisk(DiskCreateOptionType.FromImage) + { + Caching = CachingType.ReadWrite, + ManagedDisk = new VirtualMachineScaleSetManagedDisk + { + StorageAccountType = new StorageAccountType(effectiveOsDiskType) + }, + DiskSizeGB = effectiveOsDiskSizeGb + }, + ImageReference = new ImageReference + { + Publisher = publisher, + Offer = offer, + Sku = sku, + Version = version + } + }, + OSProfile = new VirtualMachineScaleSetOSProfile + { + // VMSS computer name prefix - Azure appends instance number + ComputerNamePrefix = vmssName.Length > 9 ? vmssName[..9] : vmssName, + AdminUsername = adminUsername + }, + NetworkProfile = new VirtualMachineScaleSetNetworkProfile + { + NetworkInterfaceConfigurations = + { + new VirtualMachineScaleSetNetworkConfiguration($"{vmssName}-nic") + { + Primary = true, + IPConfigurations = + { + new VirtualMachineScaleSetIPConfiguration($"{vmssName}-ipconfig") + { + Primary = true, + SubnetId = subnetId + } + } + } + } + } + } + }; + + // Configure authentication based on OS type + if (effectiveOsType.Equals("windows", StringComparison.OrdinalIgnoreCase)) + { + vmssData.VirtualMachineProfile.OSProfile.AdminPassword = adminPassword; + vmssData.VirtualMachineProfile.OSProfile.WindowsConfiguration = new WindowsConfiguration + { + ProvisionVmAgent = true, + EnableAutomaticUpdates = true + }; + } + else + { + vmssData.VirtualMachineProfile.OSProfile.LinuxConfiguration = new LinuxConfiguration + { + DisablePasswordAuthentication = string.IsNullOrEmpty(adminPassword) + }; + + if (!string.IsNullOrEmpty(sshPublicKey)) + { + var resolvedSshKey = File.Exists(sshPublicKey) + ? File.ReadAllText(sshPublicKey).Trim() + : sshPublicKey; + + vmssData.VirtualMachineProfile.OSProfile.LinuxConfiguration.SshPublicKeys.Add(new SshPublicKeyConfiguration + { + Path = $"/home/{adminUsername}/.ssh/authorized_keys", + KeyData = resolvedSshKey + }); + } + + if (!string.IsNullOrEmpty(adminPassword)) + { + vmssData.VirtualMachineProfile.OSProfile.AdminPassword = adminPassword; + vmssData.VirtualMachineProfile.OSProfile.LinuxConfiguration.DisablePasswordAuthentication = false; + } + } + + // Add availability zone if specified + if (!string.IsNullOrEmpty(zone)) + { + vmssData.Zones.Add(zone); + } + + // Create the VMSS + var vmssCollection = resourceGroupResource.GetVirtualMachineScaleSets(); + var vmssOperation = await vmssCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + vmssName, + vmssData, + cancellationToken); + + var createdVmss = vmssOperation.Value; + + return new VmssCreateResult( + Name: createdVmss.Data.Name, + Id: createdVmss.Data.Id?.ToString(), + Location: createdVmss.Data.Location.Name, + VmSize: createdVmss.Data.Sku?.Name, + ProvisioningState: createdVmss.Data.ProvisioningState, + OsType: effectiveOsType, + Capacity: (int)(createdVmss.Data.Sku?.Capacity ?? effectiveInstanceCount), + UpgradePolicy: createdVmss.Data.UpgradePolicy?.Mode?.ToString(), + Zones: createdVmss.Data.Zones?.ToList(), + Tags: createdVmss.Data.Tags as IReadOnlyDictionary, + WorkloadConfiguration: workloadConfig); + } + + public async Task UpdateVmssAsync( + string vmssName, + string resourceGroup, + string subscription, + string? vmSize = null, + int? capacity = null, + string? upgradePolicy = null, + bool? overprovision = null, + bool? enableAutoOsUpgrade = null, + string? scaleInPolicy = null, + string? tags = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + var armClient = await CreateArmClientAsync(tenant, retryPolicy, null, cancellationToken); + var subscriptionResource = armClient.GetSubscriptionResource( + SubscriptionResource.CreateResourceIdentifier(subscription)); + + var rgResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + var resourceGroupResource = rgResource.Value; + + // Get existing VMSS + var vmssCollection = resourceGroupResource.GetVirtualMachineScaleSets(); + var vmssResponse = await vmssCollection.GetAsync(vmssName, cancellationToken: cancellationToken); + var vmssResource = vmssResponse.Value; + var vmssData = vmssResource.Data; + + // Apply updates using PATCH semantics - only update what's specified + var needsUpdate = false; + + if (vmSize != null && vmssData.Sku != null) + { + vmssData.Sku.Name = vmSize; + needsUpdate = true; + } + + if (capacity.HasValue && vmssData.Sku != null) + { + vmssData.Sku.Capacity = capacity.Value; + needsUpdate = true; + } + + if (upgradePolicy != null) + { + vmssData.UpgradePolicy ??= new VirtualMachineScaleSetUpgradePolicy(); + vmssData.UpgradePolicy.Mode = ParseUpgradePolicy(upgradePolicy); + needsUpdate = true; + } + + if (overprovision.HasValue) + { + vmssData.Overprovision = overprovision.Value; + needsUpdate = true; + } + + if (enableAutoOsUpgrade.HasValue) + { + vmssData.UpgradePolicy ??= new VirtualMachineScaleSetUpgradePolicy(); + vmssData.UpgradePolicy.AutomaticOSUpgradePolicy ??= new AutomaticOSUpgradePolicy(); + vmssData.UpgradePolicy.AutomaticOSUpgradePolicy.EnableAutomaticOSUpgrade = enableAutoOsUpgrade.Value; + needsUpdate = true; + } + + if (scaleInPolicy != null) + { + vmssData.ScaleInPolicy ??= new ScaleInPolicy(); + vmssData.ScaleInPolicy.Rules.Clear(); + vmssData.ScaleInPolicy.Rules.Add(ParseScaleInPolicy(scaleInPolicy)); + needsUpdate = true; + } + + if (tags != null) + { + // Parse tags in key=value,key2=value2 format + var tagPairs = tags.Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in tagPairs) + { + var keyValue = pair.Split('=', 2); + if (keyValue.Length == 2) + { + vmssData.Tags[keyValue[0].Trim()] = keyValue[1].Trim(); + } + } + needsUpdate = true; + } + + if (needsUpdate) + { + var updateOperation = await vmssCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + vmssName, + vmssData, + cancellationToken); + vmssResource = updateOperation.Value; + } + + return new VmssUpdateResult( + Name: vmssResource.Data.Name, + Id: vmssResource.Data.Id?.ToString(), + Location: vmssResource.Data.Location.Name, + VmSize: vmssResource.Data.Sku?.Name, + ProvisioningState: vmssResource.Data.ProvisioningState, + Capacity: (int?)(vmssResource.Data.Sku?.Capacity), + UpgradePolicy: vmssResource.Data.UpgradePolicy?.Mode?.ToString(), + Zones: vmssResource.Data.Zones?.ToList(), + Tags: vmssResource.Data.Tags as IReadOnlyDictionary); + } + + public async Task UpdateVmAsync( + string vmName, + string resourceGroup, + string subscription, + string? vmSize = null, + string? tags = null, + string? licenseType = null, + string? bootDiagnostics = null, + string? userData = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + var armClient = await CreateArmClientAsync(tenant, retryPolicy, null, cancellationToken); + var subscriptionResource = armClient.GetSubscriptionResource( + SubscriptionResource.CreateResourceIdentifier(subscription)); + + var rgResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + var resourceGroupResource = rgResource.Value; + + // Get existing VM + var vmCollection = resourceGroupResource.GetVirtualMachines(); + var vmResponse = await vmCollection.GetAsync(vmName, cancellationToken: cancellationToken); + var vmResource = vmResponse.Value; + + // Build patch object - only update what's specified + var patch = new VirtualMachinePatch(); + var needsUpdate = false; + + if (vmSize != null) + { + patch.HardwareProfile = new VirtualMachineHardwareProfile { VmSize = new VirtualMachineSizeType(vmSize) }; + needsUpdate = true; + } + + if (licenseType != null) + { + patch.LicenseType = licenseType.Equals("None", StringComparison.OrdinalIgnoreCase) ? null : licenseType; + needsUpdate = true; + } + + if (bootDiagnostics != null) + { + var enabled = bootDiagnostics.Equals("true", StringComparison.OrdinalIgnoreCase) || + bootDiagnostics.Equals("enable", StringComparison.OrdinalIgnoreCase); + patch.BootDiagnostics = new BootDiagnostics { Enabled = enabled }; + needsUpdate = true; + } + + if (userData != null) + { + patch.UserData = userData; + needsUpdate = true; + } + + if (tags != null) + { + // Parse tags in key=value,key2=value2 format + var tagPairs = tags.Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in tagPairs) + { + var keyValue = pair.Split('=', 2); + if (keyValue.Length == 2) + { + patch.Tags[keyValue[0].Trim()] = keyValue[1].Trim(); + } + } + needsUpdate = true; + } + + if (needsUpdate) + { + var updateOperation = await vmResource.UpdateAsync( + Azure.WaitUntil.Completed, + patch, + cancellationToken: cancellationToken); + vmResource = updateOperation.Value; + } + + // Extract power state from instance view if available + string? powerState = null; + try + { + var instanceViewResponse = await vmResource.InstanceViewAsync(cancellationToken); + var instanceView = instanceViewResponse.Value; + powerState = instanceView.Statuses? + .FirstOrDefault(s => s.Code?.StartsWith("PowerState/", StringComparison.OrdinalIgnoreCase) == true)? + .DisplayStatus; + } + catch + { + // Instance view not always available + } + + return new VmUpdateResult( + Name: vmResource.Data.Name, + Id: vmResource.Data.Id?.ToString(), + Location: vmResource.Data.Location.Name, + VmSize: vmResource.Data.HardwareProfile?.VmSize?.ToString(), + ProvisioningState: vmResource.Data.ProvisioningState, + PowerState: powerState, + OsType: vmResource.Data.StorageProfile?.OSDisk?.OSType?.ToString(), + LicenseType: vmResource.Data.LicenseType, + Zones: vmResource.Data.Zones?.ToList(), + Tags: vmResource.Data.Tags as IReadOnlyDictionary); + } + + private static VirtualMachineScaleSetScaleInRule ParseScaleInPolicy(string scaleInPolicy) + { + return scaleInPolicy.ToLowerInvariant() switch + { + "default" => VirtualMachineScaleSetScaleInRule.Default, + "oldestvm" => VirtualMachineScaleSetScaleInRule.OldestVm, + "newestvm" => VirtualMachineScaleSetScaleInRule.NewestVm, + _ => VirtualMachineScaleSetScaleInRule.Default + }; + } + + private static VirtualMachineScaleSetUpgradeMode ParseUpgradePolicy(string? upgradePolicy) + { + if (string.IsNullOrEmpty(upgradePolicy)) + { + return VirtualMachineScaleSetUpgradeMode.Manual; + } + + return upgradePolicy.ToLowerInvariant() switch + { + "automatic" => VirtualMachineScaleSetUpgradeMode.Automatic, + "rolling" => VirtualMachineScaleSetUpgradeMode.Rolling, + _ => VirtualMachineScaleSetUpgradeMode.Manual + }; + } + + private async Task CreateOrGetVmssNetworkResourcesAsync( + ResourceGroupResource resourceGroup, + string vmssName, + string location, + string? virtualNetwork, + string? subnet, + CancellationToken cancellationToken) + { + var vnetName = virtualNetwork ?? $"{vmssName}-vnet"; + var subnetName = subnet ?? "default"; + + // Create or get VNet + var vnetCollection = resourceGroup.GetVirtualNetworks(); + VirtualNetworkResource vnetResource; + + try + { + var existingVnet = await vnetCollection.GetAsync(vnetName, cancellationToken: cancellationToken); + vnetResource = existingVnet.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + var vnetData = new VirtualNetworkData + { + Location = new AzureLocation(location), + AddressPrefixes = { "10.0.0.0/16" }, + Subnets = + { + new SubnetData + { + Name = subnetName, + AddressPrefix = "10.0.0.0/24" + } + } + }; + + var vnetOperation = await vnetCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + vnetName, + vnetData, + cancellationToken); + vnetResource = vnetOperation.Value; + } + + // Get subnet + var subnetCollection = vnetResource.GetSubnets(); + SubnetResource subnetResource; + + try + { + var existingSubnet = await subnetCollection.GetAsync(subnetName, cancellationToken: cancellationToken); + subnetResource = existingSubnet.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + var subnetData = new SubnetData + { + AddressPrefix = "10.0.1.0/24" + }; + + var subnetOperation = await subnetCollection.CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + subnetName, + subnetData, + cancellationToken); + subnetResource = subnetOperation.Value; + } + + return subnetResource.Id; + } + private static VmInfo MapToVmInfo(VirtualMachineData data) { return new VmInfo( diff --git a/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs b/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs index d34a636fa2..ae92bba138 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs @@ -40,6 +40,32 @@ Task GetVmInstanceViewAsync( RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CreateVmAsync( + string vmName, + string resourceGroup, + string subscription, + string location, + string adminUsername, + string? vmSize = null, + string? image = null, + string? adminPassword = null, + string? sshPublicKey = null, + string? workload = null, + string? osType = null, + string? virtualNetwork = null, + string? subnet = null, + string? publicIpAddress = null, + string? networkSecurityGroup = null, + bool? noPublicIp = null, + string? zone = null, + int? osDiskSizeGb = null, + string? osDiskType = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + WorkloadConfiguration GetWorkloadConfiguration(string? workload); + // Virtual Machine Scale Set operations Task GetVmssAsync( string vmssName, @@ -73,6 +99,57 @@ Task GetVmssVmAsync( RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CreateVmssAsync( + string vmssName, + string resourceGroup, + string subscription, + string location, + string adminUsername, + string? vmSize = null, + string? image = null, + string? adminPassword = null, + string? sshPublicKey = null, + string? workload = null, + string? osType = null, + string? virtualNetwork = null, + string? subnet = null, + int? instanceCount = null, + string? upgradePolicy = null, + string? zone = null, + int? osDiskSizeGb = null, + string? osDiskType = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task UpdateVmssAsync( + string vmssName, + string resourceGroup, + string subscription, + string? vmSize = null, + int? capacity = null, + string? upgradePolicy = null, + bool? overprovision = null, + bool? enableAutoOsUpgrade = null, + string? scaleInPolicy = null, + string? tags = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task UpdateVmAsync( + string vmName, + string resourceGroup, + string subscription, + string? vmSize = null, + string? tags = null, + string? licenseType = null, + string? bootDiagnostics = null, + string? userData = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + // Disk operations Task GetDiskAsync( string diskName, diff --git a/tools/Azure.Mcp.Tools.Compute/src/Utilities/ComputeUtilities.cs b/tools/Azure.Mcp.Tools.Compute/src/Utilities/ComputeUtilities.cs new file mode 100644 index 0000000000..ed00113c96 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Utilities/ComputeUtilities.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Compute.Utilities; + +internal static class ComputeUtilities +{ + /// + /// Determines the OS type based on the provided osType parameter or image name. + /// If osType is explicitly provided, it is used. Otherwise, the image name is analyzed + /// to detect Windows images. Defaults to Linux if no Windows indicators are found. + /// + /// Explicit OS type (e.g., "windows", "linux"). + /// Image name or alias to analyze. + /// The detected OS type, either "windows" or "linux". + public static string DetermineOsType(string? osType, string? image) + { + if (!string.IsNullOrEmpty(osType)) + { + return osType; + } + + if (!string.IsNullOrEmpty(image)) + { + var lowerImage = image.ToLowerInvariant(); + if (lowerImage.Contains("win") || lowerImage.Contains("windows")) + { + return "windows"; + } + } + + return "linux"; + } +} diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/ComputeCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/ComputeCommandTests.cs index 573996667e..9b3965da2f 100644 --- a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/ComputeCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/ComputeCommandTests.cs @@ -20,6 +20,13 @@ public class ComputeCommandTests(ITestOutputHelper output, TestProxyFixture fixt // Disable default sanitizer additions to avoid conflicts (following SQL pattern) public override bool EnableDefaultSanitizerAdditions => false; + // Enable --dangerously-disable-elicitation for commands with Secret = true (vm create) + public override async ValueTask InitializeAsync() + { + SetArguments("server", "start", "--mode", "all", "--dangerously-disable-elicitation"); + await base.InitializeAsync(); + } + // Sanitize resource group in URIs public override List UriRegexSanitizers => [ @@ -57,6 +64,15 @@ public class ComputeCommandTests(ITestOutputHelper output, TestProxyFixture fixt }) ]; + // Sanitize admin password in request bodies + public override List BodyKeySanitizers => + [ + new BodyKeySanitizer(new BodyKeySanitizerBody("$..adminPassword") + { + Value = "REDACTED", + }) + ]; + [Fact] public async Task Should_list_vms_in_subscription() { @@ -216,6 +232,140 @@ public async Task Should_get_specific_vmss_vm() Assert.Equal("0", returnedInstanceId.GetString()); } + #region VM Update Tests + + [Fact] + public async Task Should_create_vm_with_password_auth() + { + var createVmName = RegisterOrRetrieveVariable("createVmName", $"testvm{DateTime.UtcNow:MMddHHmmss}"); + + var result = await CallToolAsync( + "compute_vm_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "vm-name", createVmName }, + { "location", "eastus2" }, + { "admin-username", "azureuser" }, + { "admin-password", "TestP@ssw0rd123!" }, + { "image", "Ubuntu2404" }, + { "workload", "development" }, + { "no-public-ip", true } + }); + + var vm = result.AssertProperty("Vm"); + Assert.Equal(JsonValueKind.Object, vm.ValueKind); + + var provisioningState = vm.GetProperty("provisioningState"); + Assert.Equal("Succeeded", provisioningState.GetString()); + + var vmSize = vm.GetProperty("vmSize"); + Assert.Equal("Standard_B2s", vmSize.GetString()); + + var osType = vm.GetProperty("osType"); + Assert.Equal("linux", osType.GetString()); + } + + [Fact] + public async Task Should_update_vm_tags() + { + var result = await CallToolAsync( + "compute_vm_update", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "vm-name", VmName }, + { "tags", "testkey=testvalue,environment=livetests" } + }); + + var vm = result.AssertProperty("Vm"); + Assert.Equal(JsonValueKind.Object, vm.ValueKind); + + var provisioningState = vm.GetProperty("provisioningState"); + Assert.Equal("Succeeded", provisioningState.GetString()); + + // Verify tags were applied + var tags = vm.GetProperty("tags"); + Assert.Equal(JsonValueKind.Object, tags.ValueKind); + } + + [Fact] + public async Task Should_update_vm_boot_diagnostics() + { + var result = await CallToolAsync( + "compute_vm_update", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "vm-name", VmName }, + { "boot-diagnostics", "true" } + }); + + var vm = result.AssertProperty("Vm"); + Assert.Equal(JsonValueKind.Object, vm.ValueKind); + + var provisioningState = vm.GetProperty("provisioningState"); + Assert.Equal("Succeeded", provisioningState.GetString()); + } + + #endregion + + #region VMSS Update Tests + + [Fact] + public async Task Should_update_vmss_tags() + { + var result = await CallToolAsync( + "compute_vmss_update", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "vmss-name", VmssName }, + { "tags", "testkey=testvalue,environment=livetests" } + }); + + var vmss = result.AssertProperty("Vmss"); + Assert.Equal(JsonValueKind.Object, vmss.ValueKind); + + var provisioningState = vmss.GetProperty("provisioningState"); + Assert.Equal("Succeeded", provisioningState.GetString()); + + // Verify tags were applied + var tags = vmss.GetProperty("tags"); + Assert.Equal(JsonValueKind.Object, tags.ValueKind); + } + + [Fact] + public async Task Should_update_vmss_upgrade_policy() + { + var result = await CallToolAsync( + "compute_vmss_update", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "vmss-name", VmssName }, + { "upgrade-policy", "Manual" } + }); + + var vmss = result.AssertProperty("Vmss"); + Assert.Equal(JsonValueKind.Object, vmss.ValueKind); + + var provisioningState = vmss.GetProperty("provisioningState"); + Assert.Equal("Succeeded", provisioningState.GetString()); + + var upgradePolicy = vmss.GetProperty("upgradePolicy"); + Assert.Equal("Manual", upgradePolicy.GetString()); + } + + #endregion + + #region Disk Tests + [Fact] public async Task DiskGet_SpecificDisk_ReturnsValidDiskDetails() { @@ -380,4 +530,6 @@ public async Task DiskGet_WithDiskButNoResourceGroup_SearchesAcrossSubscription( Assert.NotNull(disk.GetProperty("Name").GetString()); // Name is sanitized during playback } } + + #endregion } diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/assets.json b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/assets.json index 727cf17166..2b010c355f 100644 --- a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/assets.json +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.LiveTests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.Compute.LiveTests", - "Tag": "Azure.Mcp.Tools.Compute.LiveTests_b10a9bc856" + "Tag": "Azure.Mcp.Tools.Compute.LiveTests_47e77df45b" } diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vm/VmCreateCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vm/VmCreateCommandTests.cs new file mode 100644 index 0000000000..93f5f752c3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vm/VmCreateCommandTests.cs @@ -0,0 +1,481 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.Compute.Commands; +using Azure.Mcp.Tools.Compute.Commands.Vm; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Compute.UnitTests.Vm; + +public class VmCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IComputeService _computeService; + private readonly ILogger _logger; + private readonly VmCreateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + private readonly string _knownSubscription = "sub123"; + private readonly string _knownResourceGroup = "test-rg"; + private readonly string _knownVmName = "test-vm"; + private readonly string _knownLocation = "eastus"; + private readonly string _knownAdminUsername = "azureuser"; + private readonly string _knownPassword = "TestPassword123!"; + private readonly string _knownSshKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC..."; + + public VmCreateCommandTests() + { + _computeService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_computeService); + + _serviceProvider = collection.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("--vm-name test-vm --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123!", true)] // All required + password + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --ssh-public-key ssh-rsa-key", true)] // All required + ssh key + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123! --workload development", true)] // With workload + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser", false)] // Missing auth - Linux requires SSH key or password + [InlineData("--resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123!", false)] // Missing vm-name + [InlineData("--vm-name test-vm --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123!", false)] // Missing resource-group + [InlineData("--vm-name test-vm --resource-group test-rg --location eastus --admin-username azureuser --admin-password TestPassword123!", false)] // Missing subscription + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --admin-username azureuser --admin-password TestPassword123!", false)] // Missing location + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --location eastus --admin-password TestPassword123!", false)] // Missing admin-username + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var createResult = new VmCreateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: _knownLocation, + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + OsType: "linux", + PublicIpAddress: "40.71.11.2", + PrivateIpAddress: "10.0.0.4", + Zones: null, + Tags: null, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D2s_v3", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage")); + + _computeService.CreateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(createResult); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act & Assert + if (shouldSucceed) + { + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + else + { + // For validation failures, the command may throw CommandValidationException or return BadRequest + try + { + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.False(string.IsNullOrEmpty(response.Message)); + } + catch (Microsoft.Mcp.Core.Commands.CommandValidationException) + { + // Expected for validation failures + } + } + } + + [Fact] + public async Task ExecuteAsync_CreatesVmWithLinuxSshKey() + { + // Arrange + var expectedResult = new VmCreateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: _knownLocation, + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + OsType: "linux", + PublicIpAddress: "40.71.11.2", + PrivateIpAddress: "10.0.0.4", + Zones: new List { "1" }, + Tags: new Dictionary { { "env", "test" } }, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D2s_v3", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage")); + + _computeService.CreateVmAsync( + Arg.Is(_knownVmName), + Arg.Is(_knownResourceGroup), + Arg.Is(_knownSubscription), + Arg.Is(_knownLocation), + Arg.Is(_knownAdminUsername), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(x => !string.IsNullOrEmpty(x)), // SSH key + 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(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--ssh-public-key", _knownSshKey + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmCreateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vm); + Assert.Equal(_knownVmName, result.Vm.Name); + Assert.Equal("linux", result.Vm.OsType); + Assert.Equal("40.71.11.2", result.Vm.PublicIpAddress); + } + + [Fact] + public async Task ExecuteAsync_CreatesVmWithWorkload() + { + // Arrange + var expectedResult = new VmCreateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: _knownLocation, + VmSize: "Standard_B2s", + ProvisioningState: "Succeeded", + OsType: "linux", + PublicIpAddress: "40.71.11.2", + PrivateIpAddress: "10.0.0.4", + Zones: null, + Tags: null, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "development", + SuggestedVmSize: "Standard_B2s", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 64, + Description: "Cost-effective burstable VM for development and testing workloads")); + + _computeService.CreateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is("development"), // Workload + 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(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--admin-password", _knownPassword, + "--workload", "development" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmCreateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vm); + Assert.NotNull(result.Vm.WorkloadConfiguration); + Assert.Equal("development", result.Vm.WorkloadConfiguration.WorkloadType); + Assert.Equal("Standard_B2s", result.Vm.WorkloadConfiguration.SuggestedVmSize); + } + + [Fact] + public async Task ExecuteAsync_RequiresPasswordForWindows() + { + // Arrange + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--image", "Win2022Datacenter" // Windows image + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("password", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Windows", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HandlesConflictException() + { + // Arrange + var conflictException = new RequestFailedException((int)HttpStatusCode.Conflict, "A VM with this name already exists"); + + _computeService.CreateVmAsync( + 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(conflictException); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--admin-password", _knownPassword + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("already exists", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HandlesForbiddenException() + { + // Arrange + var forbiddenException = new RequestFailedException((int)HttpStatusCode.Forbidden, "Authorization failed"); + + _computeService.CreateVmAsync( + 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(forbiddenException); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--admin-password", _knownPassword + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.Status); + Assert.Contains("Authorization failed", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var expectedResult = new VmCreateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: _knownLocation, + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + OsType: "linux", + PublicIpAddress: "40.71.11.2", + PrivateIpAddress: "10.0.0.4", + Zones: null, + Tags: null, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D2s_v3", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage")); + + _computeService.CreateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--admin-password", _knownPassword + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + + // Verify deserialization works + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmCreateCommandResult); + Assert.NotNull(result); + Assert.NotNull(result.Vm); + Assert.Equal(_knownVmName, result.Vm.Name); + Assert.NotNull(result.Vm.WorkloadConfiguration); + } +} diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vm/VmUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vm/VmUpdateCommandTests.cs new file mode 100644 index 0000000000..771f8bc6a8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vm/VmUpdateCommandTests.cs @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.Compute.Commands; +using Azure.Mcp.Tools.Compute.Commands.Vm; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Compute.UnitTests.Vm; + +public class VmUpdateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IComputeService _computeService; + private readonly ILogger _logger; + private readonly VmUpdateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + private readonly string _knownSubscription = "sub123"; + private readonly string _knownResourceGroup = "test-rg"; + private readonly string _knownVmName = "test-vm"; + + public VmUpdateCommandTests() + { + _computeService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_computeService); + + _serviceProvider = collection.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("--vm-name test-vm --resource-group test-rg --subscription sub123 --tags env=test", true)] + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --boot-diagnostics true", true)] + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --license-type Windows_Server", true)] + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123 --vm-size Standard_D4s_v3", true)] + [InlineData("--vm-name test-vm --resource-group test-rg --subscription sub123", false)] // No update property + [InlineData("--resource-group test-rg --subscription sub123 --tags env=test", false)] // Missing vm-name + [InlineData("--vm-name test-vm --subscription sub123 --tags env=test", false)] // Missing resource-group + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var updateResult = new VmUpdateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + PowerState: "VM running", + OsType: "linux", + LicenseType: null, + Zones: null, + Tags: new Dictionary { { "env", "test" } }); + + _computeService.UpdateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(updateResult); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act & Assert + if (shouldSucceed) + { + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + else + { + // For missing required options or validation failures + try + { + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + catch (Microsoft.Mcp.Core.Commands.CommandValidationException) + { + // Expected for validation failures + } + } + } + + [Fact] + public async Task ExecuteAsync_UpdatesVmWithTags() + { + // Arrange + var expectedResult = new VmUpdateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + PowerState: "VM running", + OsType: "linux", + LicenseType: null, + Zones: null, + Tags: new Dictionary { { "env", "prod" }, { "team", "compute" } }); + + _computeService.UpdateVmAsync( + Arg.Is(_knownVmName), + Arg.Is(_knownResourceGroup), + Arg.Is(_knownSubscription), + Arg.Any(), + Arg.Is("env=prod,team=compute"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--tags", "env=prod,team=compute" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmUpdateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vm); + Assert.Equal(_knownVmName, result.Vm.Name); + Assert.NotNull(result.Vm.Tags); + Assert.Equal(2, result.Vm.Tags.Count); + } + + [Fact] + public async Task ExecuteAsync_UpdatesVmWithLicenseType() + { + // Arrange + var expectedResult = new VmUpdateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + PowerState: "VM running", + OsType: "windows", + LicenseType: "Windows_Server", + Zones: null, + Tags: null); + + _computeService.UpdateVmAsync( + Arg.Is(_knownVmName), + Arg.Is(_knownResourceGroup), + Arg.Is(_knownSubscription), + Arg.Any(), + Arg.Any(), + Arg.Is("Windows_Server"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--license-type", "Windows_Server" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmUpdateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vm); + Assert.Equal("Windows_Server", result.Vm.LicenseType); + } + + [Fact] + public async Task ExecuteAsync_HandlesNotFoundError() + { + // Arrange + var notFoundException = new RequestFailedException((int)HttpStatusCode.NotFound, "VM not found"); + + _computeService.UpdateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(notFoundException); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--tags", "env=test" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.Status); + Assert.Contains("not found", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HandlesConflictError() + { + // Arrange + var conflictException = new RequestFailedException((int)HttpStatusCode.Conflict, "VM must be deallocated to change size"); + + _computeService.UpdateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(conflictException); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--vm-size", "Standard_D4s_v3" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("deallocated", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var expectedResult = new VmUpdateResult( + Name: _knownVmName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + PowerState: "VM running", + OsType: "linux", + LicenseType: null, + Zones: ["1"], + Tags: new Dictionary { { "env", "test" } }); + + _computeService.UpdateVmAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vm-name", _knownVmName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--tags", "env=test" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmUpdateCommandResult); + Assert.NotNull(result); + Assert.NotNull(result.Vm); + Assert.Equal(_knownVmName, result.Vm.Name); + } + + [Fact] + public void BindOptions_BindsOptionsCorrectly() + { + // Arrange + var parseResult = _commandDefinition.Parse( + $"--vm-name {_knownVmName} --resource-group {_knownResourceGroup} --subscription {_knownSubscription} --vm-size Standard_D4s_v3 --tags env=test --license-type Windows_Server --boot-diagnostics true --user-data dGVzdA=="); + + // Assert parse was successful + Assert.Empty(parseResult.Errors); + } +} diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vmss/VmssCreateCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vmss/VmssCreateCommandTests.cs new file mode 100644 index 0000000000..89ecf5475b --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vmss/VmssCreateCommandTests.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.Compute.Commands; +using Azure.Mcp.Tools.Compute.Commands.Vmss; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Compute.UnitTests.Vmss; + +public class VmssCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IComputeService _computeService; + private readonly ILogger _logger; + private readonly VmssCreateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + private readonly string _knownSubscription = "sub123"; + private readonly string _knownResourceGroup = "test-rg"; + private readonly string _knownVmssName = "test-vmss"; + private readonly string _knownLocation = "eastus"; + private readonly string _knownAdminUsername = "azureuser"; + private readonly string _knownPassword = "TestPassword123!"; + private readonly string _knownSshKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC..."; + + public VmssCreateCommandTests() + { + _computeService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_computeService); + + _serviceProvider = collection.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("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123!", true)] + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --ssh-public-key ssh-rsa-key", true)] + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123! --instance-count 3", true)] + [InlineData("--resource-group test-rg --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123!", false)] // Missing vmss-name + [InlineData("--vmss-name test-vmss --subscription sub123 --location eastus --admin-username azureuser --admin-password TestPassword123!", false)] // Missing resource-group + [InlineData("--vmss-name test-vmss --resource-group test-rg --location eastus --admin-username azureuser --admin-password TestPassword123!", false)] // Missing subscription + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --admin-username azureuser --admin-password TestPassword123!", false)] // Missing location + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --location eastus --admin-password TestPassword123!", false)] // Missing admin-username + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var createResult = new VmssCreateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: _knownLocation, + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + OsType: "linux", + Capacity: 2, + UpgradePolicy: "Manual", + Zones: null, + Tags: null, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D2s_v3", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage")); + + _computeService.CreateVmssAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(createResult); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (shouldSucceed) + { + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + else + { + Assert.False(string.IsNullOrEmpty(response.Message)); + } + } + + [Fact] + public async Task ExecuteAsync_CreatesVmssWithLinuxSshKey() + { + // Arrange + var expectedResult = new VmssCreateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: _knownLocation, + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + OsType: "linux", + Capacity: 3, + UpgradePolicy: "Manual", + Zones: ["1"], + Tags: new Dictionary { { "env", "test" } }, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D2s_v3", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage")); + + _computeService.CreateVmssAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--ssh-public-key", _knownSshKey, + "--instance-count", "3" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmssCreateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vmss); + Assert.Equal(_knownVmssName, result.Vmss.Name); + Assert.Equal("linux", result.Vmss.OsType); + Assert.Equal(3, result.Vmss.Capacity); + } + + [Fact] + public async Task ExecuteAsync_RequiresPasswordForWindows() + { + // Arrange + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--image", "Win2022Datacenter" // Windows image + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("password", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HandlesConflictException() + { + // Arrange + var conflictException = new RequestFailedException((int)HttpStatusCode.Conflict, "A VMSS with this name already exists"); + + _computeService.CreateVmssAsync( + 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(conflictException); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--admin-password", _knownPassword + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("already exists", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var expectedResult = new VmssCreateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: _knownLocation, + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + OsType: "linux", + Capacity: 2, + UpgradePolicy: "Manual", + Zones: null, + Tags: null, + WorkloadConfiguration: new WorkloadConfiguration( + WorkloadType: "general", + SuggestedVmSize: "Standard_D2s_v3", + SuggestedOsDiskType: "StandardSSD_LRS", + SuggestedOsDiskSizeGb: 128, + Description: "General purpose VM balanced for compute, memory, and storage")); + + _computeService.CreateVmssAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--location", _knownLocation, + "--admin-username", _knownAdminUsername, + "--admin-password", _knownPassword + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmssCreateCommandResult); + Assert.NotNull(result); + Assert.NotNull(result.Vmss); + Assert.Equal(_knownVmssName, result.Vmss.Name); + Assert.NotNull(result.Vmss.WorkloadConfiguration); + } +} diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vmss/VmssUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vmss/VmssUpdateCommandTests.cs new file mode 100644 index 0000000000..164c55de0e --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.UnitTests/Vmss/VmssUpdateCommandTests.cs @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.Compute.Commands; +using Azure.Mcp.Tools.Compute.Commands.Vmss; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Compute.UnitTests.Vmss; + +public class VmssUpdateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IComputeService _computeService; + private readonly ILogger _logger; + private readonly VmssUpdateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + private readonly string _knownSubscription = "sub123"; + private readonly string _knownResourceGroup = "test-rg"; + private readonly string _knownVmssName = "test-vmss"; + + public VmssUpdateCommandTests() + { + _computeService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_computeService); + + _serviceProvider = collection.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("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --upgrade-policy Automatic", true)] + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --tags env=test", true)] + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123 --scale-in-policy OldestVM", true)] + [InlineData("--vmss-name test-vmss --resource-group test-rg --subscription sub123", false)] // No update property + [InlineData("--resource-group test-rg --subscription sub123 --tags env=test", false)] // Missing vmss-name + [InlineData("--vmss-name test-vmss --subscription sub123 --tags env=test", false)] // Missing resource-group + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var updateResult = new VmssUpdateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + Capacity: 5, + UpgradePolicy: "Manual", + Zones: null, + Tags: null); + + _computeService.UpdateVmssAsync( + 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(updateResult); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act & Assert + if (shouldSucceed) + { + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + else + { + // For missing required options, we expect BadRequest or exception + try + { + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + catch (Microsoft.Mcp.Core.Commands.CommandValidationException) + { + // Expected for validation failures + } + } + } + + [Fact] + public async Task ExecuteAsync_UpdatesVmssTags() + { + // Arrange + var expectedResult = new VmssUpdateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + Capacity: 5, + UpgradePolicy: "Manual", + Zones: null, + Tags: new Dictionary { { "env", "prod" } }); + + _computeService.UpdateVmssAsync( + 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(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--tags", "env=prod" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmssUpdateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vmss); + Assert.Equal(_knownVmssName, result.Vmss.Name); + Assert.NotNull(result.Vmss.Tags); + Assert.Equal("prod", result.Vmss.Tags["env"]); + } + + [Fact] + public async Task ExecuteAsync_UpdatesVmssUpgradePolicy() + { + // Arrange + var expectedResult = new VmssUpdateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + Capacity: 5, + UpgradePolicy: "Automatic", + Zones: null, + Tags: null); + + _computeService.UpdateVmssAsync( + Arg.Is(_knownVmssName), + Arg.Is(_knownResourceGroup), + Arg.Is(_knownSubscription), + Arg.Any(), + Arg.Any(), + Arg.Is("Automatic"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResult); + + var parseResult = _commandDefinition.Parse( + $"--vmss-name {_knownVmssName} --resource-group {_knownResourceGroup} --subscription {_knownSubscription} --upgrade-policy Automatic"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmssUpdateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.Vmss); + Assert.Equal("Automatic", result.Vmss.UpgradePolicy); + } + + [Fact] + public async Task ExecuteAsync_HandlesNotFoundError() + { + // Arrange + var notFoundException = new RequestFailedException((int)HttpStatusCode.NotFound, "VMSS not found"); + + _computeService.UpdateVmssAsync( + 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(notFoundException); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--tags", "env=test" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.Status); + Assert.Contains("not found", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HandlesQuotaExceeded() + { + // Arrange + var quotaException = new RequestFailedException((int)HttpStatusCode.BadRequest, "Quota exceeded for VM size in region"); + + _computeService.UpdateVmssAsync( + 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(quotaException); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--vm-size", "Standard_D4s_v3" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("quota", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var expectedResult = new VmssUpdateResult( + Name: _knownVmssName, + Id: "/subscriptions/sub123/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachineScaleSets/test-vmss", + Location: "eastus", + VmSize: "Standard_D2s_v3", + ProvisioningState: "Succeeded", + Capacity: 5, + UpgradePolicy: "Manual", + Zones: ["1"], + Tags: new Dictionary { { "env", "test" } }); + + _computeService.UpdateVmssAsync( + 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(expectedResult); + + var parseResult = _commandDefinition.Parse([ + "--vmss-name", _knownVmssName, + "--resource-group", _knownResourceGroup, + "--subscription", _knownSubscription, + "--tags", "env=test" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + + var result = JsonSerializer.Deserialize(json, ComputeJsonContext.Default.VmssUpdateCommandResult); + Assert.NotNull(result); + Assert.NotNull(result.Vmss); + Assert.Equal(_knownVmssName, result.Vmss.Name); + } + + [Fact] + public void BindOptions_BindsOptionsCorrectly() + { + // Arrange + var parseResult = _commandDefinition.Parse( + $"--vmss-name {_knownVmssName} --resource-group {_knownResourceGroup} --subscription {_knownSubscription} --capacity 5 --upgrade-policy Automatic --scale-in-policy OldestVM --tags env=test"); + + // Assert parse was successful + Assert.Empty(parseResult.Errors); + } +} diff --git a/tools/Azure.Mcp.Tools.Compute/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.Compute/tests/test-resources.bicep index 9c45014838..8d3c78dc69 100644 --- a/tools/Azure.Mcp.Tools.Compute/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.Compute/tests/test-resources.bicep @@ -6,7 +6,7 @@ targetScope = 'resourceGroup' param baseName string = resourceGroup().name @description('The location of the resource. By default, this is the same as the resource group.') -param location string = 'westus2' +param location string = resourceGroup().location @description('The client OID to grant access to test resources.') param testApplicationOid string @@ -20,7 +20,7 @@ param adminUsername string = 'azureuser' param adminPassword string = newGuid() @description('The VM size to use for testing.') -param vmSize string = 'Standard_D2s_v6' +param vmSize string = 'Standard_B2s' // Virtual Network resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = { @@ -63,7 +63,7 @@ resource nic 'Microsoft.Network/networkInterfaces@2023-05-01' = { } // Test Virtual Machine (Linux) -resource vm 'Microsoft.Compute/virtualMachines@2024-03-01' = { +resource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = { name: '${baseName}-vm' location: location properties: { @@ -83,7 +83,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-03-01' = { storageAccountType: 'Standard_LRS' } } - diskControllerType: 'NVMe' + diskControllerType: 'SCSI' } osProfile: { computerName: '${baseName}-vm' @@ -111,7 +111,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-03-01' = { } // Virtual Machine Scale Set for VMSS testing -resource vmss 'Microsoft.Compute/virtualMachineScaleSets@2024-03-01' = { +resource vmss 'Microsoft.Compute/virtualMachineScaleSets@2023-09-01' = { name: '${baseName}-vmss' location: location sku: { @@ -138,7 +138,7 @@ resource vmss 'Microsoft.Compute/virtualMachineScaleSets@2024-03-01' = { storageAccountType: 'Standard_LRS' } } - diskControllerType: 'NVMe' + diskControllerType: 'SCSI' } osProfile: { computerNamePrefix: '${baseName}-' @@ -212,6 +212,34 @@ resource appReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-0 } } +// Network Contributor role for creating network resources (NSG, VNet, NIC, Public IP) +resource networkContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { + scope: subscription() + // This is the Network Contributor role + // Lets you manage networks, but not access to them + // See https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#network-contributor + name: '4d97b98b-1d4f-4787-a291-c67834d212e7' +} + +// Assign Network Contributor role to test application for VM create tests +resource appNetworkContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(networkContributorRoleDefinition.id, testApplicationOid, resourceGroup().id) + scope: resourceGroup() + properties: { + principalId: testApplicationOid + roleDefinitionId: networkContributorRoleDefinition.id + description: 'Network Contributor for testApplicationOid - required for VM create tests' + } +} + +// Output values for test consumption +output vmName string = vm.name +output vmssName string = vmss.name +output vnetName string = vnet.name +output resourceGroupName string = resourceGroup().name +output diskName string = testDisk.name +output location string = location + // Create a test managed disk resource testDisk 'Microsoft.Compute/disks@2023-10-02' = { name: '${baseName}-disk' @@ -232,25 +260,17 @@ resource testDisk 'Microsoft.Compute/disks@2023-10-02' = { } // Assign Contributor role for managing disks -resource contributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { +resource diskContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { scope: subscription() // Contributor role name: 'b24988ac-6180-42a0-ab88-20f7382dd24c' } resource diskContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(contributorRoleDefinition.id, testApplicationOid, testDisk.id) + name: guid(diskContributorRoleDefinition.id, testApplicationOid, testDisk.id) scope: testDisk properties: { - roleDefinitionId: contributorRoleDefinition.id + roleDefinitionId: diskContributorRoleDefinition.id principalId: testApplicationOid } } - -// Output values for test consumption -output vmName string = vm.name -output vmssName string = vmss.name -output vnetName string = vnet.name -output resourceGroupName string = resourceGroup().name -output diskName string = testDisk.name -output location string = location diff --git a/tools/Azure.Mcp.Tools.Compute/tests/test-resources.json b/tools/Azure.Mcp.Tools.Compute/tests/test-resources.json index 9e2d7e0ad1..a5662c48c2 100644 --- a/tools/Azure.Mcp.Tools.Compute/tests/test-resources.json +++ b/tools/Azure.Mcp.Tools.Compute/tests/test-resources.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "14469809156686005433" + "version": "0.39.26.7824", + "templateHash": "15386900013071109327" } }, "parameters": { @@ -20,14 +20,13 @@ }, "location": { "type": "string", - "defaultValue": "westus2", + "defaultValue": "[resourceGroup().location]", "metadata": { "description": "The location of the resource. By default, this is the same as the resource group." } }, "testApplicationOid": { "type": "string", - "defaultValue": "", "metadata": { "description": "The client OID to grant access to test resources." } @@ -48,7 +47,7 @@ }, "vmSize": { "type": "string", - "defaultValue": "Standard_D2s_v6", + "defaultValue": "Standard_B2s", "metadata": { "description": "The VM size to use for testing." } @@ -100,7 +99,7 @@ }, { "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2024-03-01", + "apiVersion": "2023-09-01", "name": "[format('{0}-vm', parameters('baseName'))]", "location": "[parameters('location')]", "properties": { @@ -120,7 +119,7 @@ "storageAccountType": "Standard_LRS" } }, - "diskControllerType": "NVMe" + "diskControllerType": "SCSI" }, "osProfile": { "computerName": "[format('{0}-vm', parameters('baseName'))]", @@ -151,7 +150,7 @@ }, { "type": "Microsoft.Compute/virtualMachineScaleSets", - "apiVersion": "2024-03-01", + "apiVersion": "2023-09-01", "name": "[format('{0}-vmss', parameters('baseName'))]", "location": "[parameters('location')]", "sku": { @@ -178,7 +177,7 @@ "storageAccountType": "Standard_LRS" } }, - "diskControllerType": "NVMe" + "diskControllerType": "SCSI" }, "osProfile": { "computerNamePrefix": "[format('{0}-', parameters('baseName'))]", @@ -234,6 +233,16 @@ "description": "Reader for testApplicationOid" } }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4d97b98b-1d4f-4787-a291-c67834d212e7'), parameters('testApplicationOid'), resourceGroup().id)]", + "properties": { + "principalId": "[parameters('testApplicationOid')]", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4d97b98b-1d4f-4787-a291-c67834d212e7')]", + "description": "Network Contributor for testApplicationOid - required for VM create tests" + } + }, { "type": "Microsoft.Compute/disks", "apiVersion": "2023-10-02", @@ -256,7 +265,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Compute/disks', format('{0}-disk', parameters('baseName')))]", + "scope": "[format('Microsoft.Compute/disks/{0}', format('{0}-disk', parameters('baseName')))]", "name": "[guid(subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c'), parameters('testApplicationOid'), resourceId('Microsoft.Compute/disks', format('{0}-disk', parameters('baseName'))))]", "properties": { "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", diff --git a/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.LiveTests/assets.json b/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.LiveTests/assets.json index 0000352247..2e1533f5f2 100644 --- a/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.LiveTests/assets.json +++ b/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.LiveTests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.KeyVault.LiveTests", - "Tag": "Azure.Mcp.Tools.KeyVault.LiveTests_24520a9311" + "Tag": "Azure.Mcp.Tools.KeyVault.LiveTests_857e94b0a0" }