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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics.CodeAnalysis;
using Azure.Mcp.Core.Options;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Microsoft.Mcp.Core.Commands;

namespace Azure.Mcp.Core.Commands.Subscription;

public abstract class SubscriptionCommand<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions, TResult>
: AuthenticatedCommand<TOptions, TResult> where TOptions : class, ISubscriptionOption
{
private readonly ISubscriptionResolver _subscriptionResolver;

protected SubscriptionCommand(ISubscriptionResolver subscriptionResolver)
{
_subscriptionResolver = subscriptionResolver;
}

public override void ValidateOptions(TOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult);

if (string.IsNullOrEmpty(options.Subscription))
{
validationResult.Errors.Add("Missing Required options: --subscription");
}
}

public override TOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
// Always post-process subscription via resolver (env var / CLI profile fallback)
options.Subscription = _subscriptionResolver.ResolveSubscription(options.Subscription);
return options;
}
}
9 changes: 9 additions & 0 deletions core/Azure.Mcp.Core/src/Options/ISubscriptionOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.Mcp.Core.Options;

public interface ISubscriptionOption
{
string? Subscription { get; set; }
}
32 changes: 32 additions & 0 deletions core/Azure.Mcp.Core/src/Options/OptionDescriptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.Mcp.Core.Options;

/// <summary>
/// Description constants for global options shared across all Azure MCP commands.
/// Used with <see cref="Microsoft.Mcp.Core.Options.OptionAttribute"/> on options classes.
/// </summary>
public static class OptionDescriptions
{
public const string Subscription =
"Specifies the Azure subscription to use. Accepts either a subscription ID (GUID) or display name. " +
"If not specified, the AZURE_SUBSCRIPTION_ID environment variable will be used instead.";
Comment thread
hallipr marked this conversation as resolved.

public const string Tenant =
Comment thread
hallipr marked this conversation as resolved.
"The Microsoft Entra ID tenant ID or name. " +
"This can be either the GUID identifier or the display name of your Entra ID tenant.";

public const string AuthMethod =
"Authentication method to use. " +
"Options: 'credential' (Azure CLI/managed identity), 'key' (access key), or 'connectionString'.";

public const string ResourceGroup =
"The name of the Azure resource group. This is a logical container for Azure resources.";

public const string Scope =
"Scope at which the role assignment or definition applies to, " +
"e.g., /subscriptions/0b1f6471-1bf0-4dda-aec3-111122223333, " +
"/subscriptions/0b1f6471-1bf0-4dda-aec3-111122223333/resourceGroups/myGroup, " +
"or /subscriptions/0b1f6471-1bf0-4dda-aec3-111122223333/resourceGroups/myGroup/providers/Microsoft.Compute/virtualMachines/myVM.";
}
46 changes: 46 additions & 0 deletions core/Azure.Mcp.Core/src/Options/RetryOptionsExtensions.cs
Comment thread
hallipr marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Text;
Comment thread
hallipr marked this conversation as resolved.
using Azure.Core;
using Microsoft.Mcp.Core.Options;

namespace Azure.Mcp.Core.Options;

public static class RetryOptionsExtensions
{
public static void ConfigureRetryOptions(this ClientOptions clientOptions, RetryPolicyOptions? retryPolicy)
Comment thread
hallipr marked this conversation as resolved.
{
if (retryPolicy == null)
{
return;
}

if (retryPolicy.MaxRetries.HasValue)
{
clientOptions.Retry.MaxRetries = retryPolicy.MaxRetries.Value;
}

if (retryPolicy.Mode.HasValue)
{
clientOptions.Retry.Mode = retryPolicy.Mode.Value;
}

if (retryPolicy.DelaySeconds.HasValue)
{
clientOptions.Retry.Delay = TimeSpan.FromSeconds(retryPolicy.DelaySeconds.Value);
}

if (retryPolicy.MaxDelaySeconds.HasValue)
{
clientOptions.Retry.MaxDelay = TimeSpan.FromSeconds(retryPolicy.MaxDelaySeconds.Value);
}

if (retryPolicy.NetworkTimeoutSeconds.HasValue)
{
clientOptions.Retry.NetworkTimeout = TimeSpan.FromSeconds(retryPolicy.NetworkTimeoutSeconds.Value);
}
}
}
28 changes: 14 additions & 14 deletions core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,33 +214,33 @@ protected static T ConfigureRetryPolicy<T>(T clientOptions, RetryPolicyOptions?
{
if (retryPolicy != null)
{
if (retryPolicy.HasDelaySeconds)
if (retryPolicy.DelaySeconds is { } delaySeconds)
{
clientOptions.Retry.Delay = s_retryLimitsDisabled
? TimeSpan.FromSeconds(retryPolicy.DelaySeconds)
: TimeSpan.FromSeconds(Math.Clamp(retryPolicy.DelaySeconds, MinAllowedDelaySeconds, MaxAllowedDelaySeconds));
? TimeSpan.FromSeconds(delaySeconds)
: TimeSpan.FromSeconds(Math.Clamp(delaySeconds, MinAllowedDelaySeconds, MaxAllowedDelaySeconds));
}
if (retryPolicy.HasMaxDelaySeconds)
if (retryPolicy.MaxDelaySeconds is { } maxDelaySeconds)
{
clientOptions.Retry.MaxDelay = s_retryLimitsDisabled
? TimeSpan.FromSeconds(retryPolicy.MaxDelaySeconds)
: TimeSpan.FromSeconds(Math.Clamp(retryPolicy.MaxDelaySeconds, MinAllowedDelaySeconds, MaxAllowedDelaySeconds));
? TimeSpan.FromSeconds(maxDelaySeconds)
: TimeSpan.FromSeconds(Math.Clamp(maxDelaySeconds, MinAllowedDelaySeconds, MaxAllowedDelaySeconds));
}
if (retryPolicy.HasMaxRetries)
if (retryPolicy.MaxRetries is { } maxRetries)
{
clientOptions.Retry.MaxRetries = s_retryLimitsDisabled
? retryPolicy.MaxRetries
: Math.Min(MaxAllowedRetries, retryPolicy.MaxRetries);
? maxRetries
: Math.Min(MaxAllowedRetries, maxRetries);
}
if (retryPolicy.HasMode)
if (retryPolicy.Mode is { } mode)
{
clientOptions.Retry.Mode = retryPolicy.Mode;
clientOptions.Retry.Mode = mode;
}
if (retryPolicy.HasNetworkTimeoutSeconds)
if (retryPolicy.NetworkTimeoutSeconds is { } networkTimeoutSeconds)
{
clientOptions.Retry.NetworkTimeout = s_retryLimitsDisabled
? TimeSpan.FromSeconds(retryPolicy.NetworkTimeoutSeconds)
: TimeSpan.FromSeconds(Math.Min(MaxAllowedNetworkTimeoutSeconds, retryPolicy.NetworkTimeoutSeconds));
? TimeSpan.FromSeconds(networkTimeoutSeconds)
: TimeSpan.FromSeconds(Math.Min(MaxAllowedNetworkTimeoutSeconds, networkTimeoutSeconds));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.CommandLine.Parsing;

namespace Azure.Mcp.Core.Services.Azure.Subscription;

/// <summary>
/// Resolves subscription values from command-line arguments, environment variables,
/// and Azure CLI profile, providing a testable seam for subscription resolution.
/// </summary>
public interface ISubscriptionResolver
{
/// <summary>
/// Resolves the subscription from the provided value, falling back to Azure CLI profile
/// or AZURE_SUBSCRIPTION_ID environment variable.
/// </summary>
string? ResolveSubscription(string? subscription);

/// <summary>
/// Checks if a subscription is available from the command option, Azure CLI profile,
/// or AZURE_SUBSCRIPTION_ID environment variable.
/// </summary>
bool HasSubscriptionAvailable(CommandResult commandResult);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.CommandLine.Parsing;
using Microsoft.Mcp.Core.Helpers;

namespace Azure.Mcp.Core.Services.Azure.Subscription;

/// <summary>
/// Default implementation that resolves subscriptions from command-line arguments,
/// Azure CLI profile, and the AZURE_SUBSCRIPTION_ID environment variable.
/// </summary>
public sealed class SubscriptionResolver : ISubscriptionResolver
{
public string? ResolveSubscription(string? subscription)
{
subscription = subscription?.Trim('"', '\'');
subscription = CommandHelper.GetSubscription(subscription);
return subscription;
}

public bool HasSubscriptionAvailable(CommandResult commandResult) =>
CommandHelper.HasSubscriptionAvailable(commandResult);
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ public static IServiceCollection SetupCommonServices()
.AddSingleton(Substitute.For<IDateTimeProvider>())
.AddSingleton(Substitute.For<IExternalProcessService>())
.AddSingleton(Substitute.For<IAzureTokenCredentialProvider>())
.AddSingleton(Substitute.For<IAzureCloudConfiguration>());
.AddSingleton(Substitute.For<IAzureCloudConfiguration>())
.AddSingleton(Substitute.For<ISubscriptionResolver>());

foreach (var area in areaSetups)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,20 @@ public void TestEqualityOperators_WithFlagsSet()
}

[Fact]
public void Policies_With_DifferentValues_But_UnsetFlags_AreEqual()
public void Policies_With_AllNullValues_AreEqual()
{
// Create two policies differing in values but with no flags set (simulate deserialization or manual construction without flags)
var p1 = new RetryPolicyOptions { MaxRetries = 3, Mode = RetryMode.Exponential, DelaySeconds = 1, MaxDelaySeconds = 5, NetworkTimeoutSeconds = 30 };
var p2 = new RetryPolicyOptions { MaxRetries = 999, Mode = RetryMode.Fixed, DelaySeconds = 10, MaxDelaySeconds = 50, NetworkTimeoutSeconds = 0 };
// Since no Has* flags are set, differences are ignored.
// Policies with no values set are equal — both are "use SDK defaults"
var p1 = new RetryPolicyOptions();
var p2 = new RetryPolicyOptions();
Assert.True(RetryPolicyOptions.AreEqual(p1, p2));
Assert.True(p1 == p2);
}

[Fact]
public void Policies_With_Mismatched_Flags_NotEqual()
public void Policies_With_SomeValuesSet_VsNone_NotEqual()
{
var pSpecified = GetPolicy(3, RetryMode.Exponential, 1, 5, 30); // flags set
var pUnspecified = new RetryPolicyOptions { MaxRetries = 3, Mode = RetryMode.Exponential, DelaySeconds = 1, MaxDelaySeconds = 5, NetworkTimeoutSeconds = 30 }; // flags unset
var pSpecified = GetPolicy(3, RetryMode.Exponential, 1, 5, 30);
var pUnspecified = new RetryPolicyOptions();
Assert.False(RetryPolicyOptions.AreEqual(pSpecified, pUnspecified));
Assert.True(pSpecified != pUnspecified);
}
Expand All @@ -72,11 +71,6 @@ private static RetryPolicyOptions GetPolicy(int maxRetries, RetryMode mode, doub
DelaySeconds = delay,
MaxDelaySeconds = maxDelay,
NetworkTimeoutSeconds = timeout,
HasMaxRetries = true,
HasMode = true,
HasDelaySeconds = true,
HasMaxDelaySeconds = true,
HasNetworkTimeoutSeconds = true
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,9 @@ public void ConfigureRetryPolicy_DisableRetryLimits_BypassesBoundsOnAllValues()
var retryPolicy = new RetryPolicyOptions
{
MaxRetries = 100,
HasMaxRetries = true,
DelaySeconds = 200,
HasDelaySeconds = true,
MaxDelaySeconds = 500,
HasMaxDelaySeconds = true,
NetworkTimeoutSeconds = 1000,
HasNetworkTimeoutSeconds = true
};
var clientOptions = new ArmClientOptions();

Expand Down Expand Up @@ -80,9 +76,7 @@ public void ConfigureRetryPolicy_DisableRetryLimits_AllowsVerySmallDelays()
var retryPolicy = new RetryPolicyOptions
{
DelaySeconds = 0.001,
HasDelaySeconds = true,
MaxDelaySeconds = 0.005,
HasMaxDelaySeconds = true
};
var clientOptions = new ArmClientOptions();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,11 @@ public async Task GetArmAccessTokenAsync_NullTenant_PassesNullToGetTokenCredenti
[InlineData(5, true, 5)] // below cap, should remain unchanged
[InlineData(10, true, 10)] // at cap, should remain unchanged
[InlineData(20, true, 10)] // above cap, should be capped
[InlineData(20, false, null)] // HasMaxRetries = false, should not override default
[InlineData(20, false, null)] // null MaxRetries, should not override default
public void ConfigureRetryPolicy_RespectsAndCapsMaxRetries(int maxRetries, bool hasMaxRetries, int? expectedMaxRetries)
{
// Arrange
var retryPolicy = new RetryPolicyOptions { MaxRetries = maxRetries, HasMaxRetries = hasMaxRetries };
var retryPolicy = new RetryPolicyOptions { MaxRetries = hasMaxRetries ? maxRetries : null };
var clientOptions = new ArmClientOptions();
var defaultClientOptions = new ArmClientOptions();
// Act
Expand All @@ -239,11 +239,11 @@ public void ConfigureRetryPolicy_RespectsAndCapsMaxRetries(int maxRetries, bool
[InlineData(0.1, true, 0.1)] // at lower cap, should remain unchanged
[InlineData(120.0, true, 60.0)] // above upper cap, should be capped to 60
[InlineData(0.01, true, 0.1)] // below lower cap, should be raised to 0.1
[InlineData(120.0, false, null)] // HasDelaySeconds = false, should not override default
[InlineData(120.0, false, null)] // null DelaySeconds, should not override default
public void ConfigureRetryPolicy_RespectsAndClampsDelay(double delaySeconds, bool hasDelay, double? expectedDelay)
{
// Arrange
var retryPolicy = new RetryPolicyOptions { DelaySeconds = delaySeconds, HasDelaySeconds = hasDelay };
var retryPolicy = new RetryPolicyOptions { DelaySeconds = hasDelay ? delaySeconds : null };
var clientOptions = new ArmClientOptions();
var defaultClientOptions = new ArmClientOptions();
// Act
Expand All @@ -259,11 +259,11 @@ public void ConfigureRetryPolicy_RespectsAndClampsDelay(double delaySeconds, boo
[InlineData(0.1, true, 0.1)] // at lower cap, should remain unchanged
[InlineData(120.0, true, 60.0)] // above upper cap, should be capped to 60
[InlineData(0.01, true, 0.1)] // below lower cap, should be raised to 0.1
[InlineData(120.0, false, null)] // HasMaxDelaySeconds = false, should not override default
[InlineData(120.0, false, null)] // null MaxDelaySeconds, should not override default
public void ConfigureRetryPolicy_RespectsAndClampsMaxDelay(double maxDelaySeconds, bool hasMaxDelay, double? expectedMaxDelay)
{
// Arrange
var retryPolicy = new RetryPolicyOptions { MaxDelaySeconds = maxDelaySeconds, HasMaxDelaySeconds = hasMaxDelay };
var retryPolicy = new RetryPolicyOptions { MaxDelaySeconds = hasMaxDelay ? maxDelaySeconds : null };
var clientOptions = new ArmClientOptions();
var defaultClientOptions = new ArmClientOptions();
// Act
Expand All @@ -277,11 +277,11 @@ public void ConfigureRetryPolicy_RespectsAndClampsMaxDelay(double maxDelaySecond
[InlineData(30.0, true, 30.0)] // within bounds, should remain unchanged
[InlineData(300.0, true, 300.0)] // at cap, should remain unchanged
[InlineData(600.0, true, 300.0)] // above cap, should be capped to 300
[InlineData(600.0, false, null)] // HasNetworkTimeoutSeconds = false, should not override default
[InlineData(600.0, false, null)] // null NetworkTimeoutSeconds, should not override default
public void ConfigureRetryPolicy_RespectsAndCapsNetworkTimeout(double networkTimeoutSeconds, bool hasNetworkTimeout, double? expectedTimeout)
{
// Arrange
var retryPolicy = new RetryPolicyOptions { NetworkTimeoutSeconds = networkTimeoutSeconds, HasNetworkTimeoutSeconds = hasNetworkTimeout };
var retryPolicy = new RetryPolicyOptions { NetworkTimeoutSeconds = hasNetworkTimeout ? networkTimeoutSeconds : null };
var clientOptions = new ArmClientOptions();
var defaultClientOptions = new ArmClientOptions();
// Act
Expand Down
Loading