diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml
index 65153017..5f3694b4 100644
--- a/.github/workflows/validate-build.yml
+++ b/.github/workflows/validate-build.yml
@@ -25,7 +25,12 @@ jobs:
with:
submodules: true
- - name: Setup .NET
+ - name: Setup .NET 6.0
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: '6.0.x'
+
+ - name: Setup .NET from global.json
uses: actions/setup-dotnet@v3
with:
global-json-file: global.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81265a38..3c9789ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
- Implement work item completion tokens for standalone worker scenarios ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359))
- Support for worker concurrency configuration ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359))
- Bump System.Text.Json to 6.0.10
+- Initial support for the Azure-managed [Durable Task Scheduler](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) preview.
## v1.4.0
diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln
index 2b8bab8a..a3a2af17 100644
--- a/Microsoft.DurableTask.sln
+++ b/Microsoft.DurableTask.sln
@@ -71,6 +71,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers.Tests", "test\Ana
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureFunctionsApp.Tests", "samples\AzureFunctionsUnitTests\AzureFunctionsApp.Tests.csproj", "{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker.AzureManaged", "src\Worker\AzureManaged\Worker.AzureManaged.csproj", "{6106872F-A730-4A75-9267-1B2E2C2DC18C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.AzureManaged", "src\Client\AzureManaged\Client.AzureManaged.csproj", "{EAA6BE9B-E1A5-4E41-9511-EFEA24A51BA3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.AzureManaged.Tests", "test\Shared\AzureManaged.Tests\Shared.AzureManaged.Tests.csproj", "{11357B31-9A63-4A5A-9BC5-091952B25BC0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.AzureManaged.Tests", "test\Client\AzureManaged.Tests\Client.AzureManaged.Tests.csproj", "{A15BA625-DC6B-4C6D-8673-0CB08F1B9737}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker.AzureManaged.Tests", "test\Worker\AzureManaged.Tests\Worker.AzureManaged.Tests.csproj", "{B78F1FFD-47AC-45BE-8FF9-0BF8C9F35DEF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -185,6 +195,34 @@ Global
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6106872F-A730-4A75-9267-1B2E2C2DC18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6106872F-A730-4A75-9267-1B2E2C2DC18C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6106872F-A730-4A75-9267-1B2E2C2DC18C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6106872F-A730-4A75-9267-1B2E2C2DC18C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EAA6BE9B-E1A5-4E41-9511-EFEA24A51BA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EAA6BE9B-E1A5-4E41-9511-EFEA24A51BA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EAA6BE9B-E1A5-4E41-9511-EFEA24A51BA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EAA6BE9B-E1A5-4E41-9511-EFEA24A51BA3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {11357B31-9A63-4A5A-9BC5-091952B25BC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11357B31-9A63-4A5A-9BC5-091952B25BC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11357B31-9A63-4A5A-9BC5-091952B25BC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11357B31-9A63-4A5A-9BC5-091952B25BC0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A15BA625-DC6B-4C6D-8673-0CB08F1B9737}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A15BA625-DC6B-4C6D-8673-0CB08F1B9737}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A15BA625-DC6B-4C6D-8673-0CB08F1B9737}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A15BA625-DC6B-4C6D-8673-0CB08F1B9737}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B78F1FFD-47AC-45BE-8FF9-0BF8C9F35DEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B78F1FFD-47AC-45BE-8FF9-0BF8C9F35DEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B78F1FFD-47AC-45BE-8FF9-0BF8C9F35DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B78F1FFD-47AC-45BE-8FF9-0BF8C9F35DEF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {869D2D51-9372-4764-B059-C43B6C1180A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {869D2D51-9372-4764-B059-C43B6C1180A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {869D2D51-9372-4764-B059-C43B6C1180A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {869D2D51-9372-4764-B059-C43B6C1180A3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D4C87C0F-66CD-459D-B271-340C6D180448}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D4C87C0F-66CD-459D-B271-340C6D180448}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D4C87C0F-66CD-459D-B271-340C6D180448}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D4C87C0F-66CD-459D-B271-340C6D180448}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -220,6 +258,11 @@ Global
{998E9D97-BD36-4A9D-81FC-5DAC1CE40083} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
{541FCCCE-1059-4691-B027-F761CD80DE92} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
+ {6106872F-A730-4A75-9267-1B2E2C2DC18C} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
+ {EAA6BE9B-E1A5-4E41-9511-EFEA24A51BA3} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
+ {11357B31-9A63-4A5A-9BC5-091952B25BC0} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
+ {A15BA625-DC6B-4C6D-8673-0CB08F1B9737} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
+ {B78F1FFD-47AC-45BE-8FF9-0BF8C9F35DEF} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
diff --git a/src/Client/AzureManaged/Client.AzureManaged.csproj b/src/Client/AzureManaged/Client.AzureManaged.csproj
new file mode 100644
index 00000000..98202006
--- /dev/null
+++ b/src/Client/AzureManaged/Client.AzureManaged.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net6.0
+ Azure Managed extensions for the Durable Task Framework client.
+ true
+ preview.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs b/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs
new file mode 100644
index 00000000..0bba1c96
--- /dev/null
+++ b/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Microsoft.DurableTask.Client.Grpc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.DurableTask.Client.AzureManaged;
+
+///
+/// Extension methods for configuring Durable Task clients to use the Azure Durable Task Scheduler service.
+///
+public static class DurableTaskSchedulerClientExtensions
+{
+ ///
+ /// Configures Durable Task client to use the Azure Durable Task Scheduler service.
+ ///
+ /// The Durable Task client builder to configure.
+ /// The endpoint address of the Durable Task Scheduler resource. Expected to be in the format "https://{scheduler-name}.{region}.durabletask.io".
+ /// The name of the task hub resource associated with the Durable Task Scheduler resource.
+ /// The credential used to authenticate with the Durable Task Scheduler task hub resource.
+ /// Optional callback to dynamically configure DurableTaskSchedulerClientOptions.
+ public static void UseDurableTaskScheduler(
+ this IDurableTaskClientBuilder builder,
+ string endpointAddress,
+ string taskHubName,
+ TokenCredential credential,
+ Action? configure = null)
+ {
+ ConfigureSchedulerOptions(
+ builder,
+ options =>
+ {
+ options.EndpointAddress = endpointAddress;
+ options.TaskHubName = taskHubName;
+ options.Credential = credential;
+ },
+ configure);
+ }
+
+ ///
+ /// Configures Durable Task client to use the Azure Durable Task Scheduler service using a connection string.
+ ///
+ /// The Durable Task client builder to configure.
+ /// The connection string used to connect to the Durable Task Scheduler service.
+ /// Optional callback to dynamically configure DurableTaskSchedulerClientOptions.
+ public static void UseDurableTaskScheduler(
+ this IDurableTaskClientBuilder builder,
+ string connectionString,
+ Action? configure = null)
+ {
+ var connectionOptions = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+ ConfigureSchedulerOptions(
+ builder,
+ options =>
+ {
+ options.EndpointAddress = connectionOptions.EndpointAddress;
+ options.TaskHubName = connectionOptions.TaskHubName;
+ options.Credential = connectionOptions.Credential;
+ },
+ configure);
+ }
+
+ ///
+ /// Configures Durable Task client to use the Azure Durable Task Scheduler service using configuration options.
+ ///
+ /// The Durable Task client builder to configure.
+ /// Callback to configure DurableTaskSchedulerClientOptions.
+ public static void UseDurableTaskScheduler(
+ this IDurableTaskClientBuilder builder,
+ Action? configure = null)
+ {
+ ConfigureSchedulerOptions(builder, _ => { }, configure);
+ }
+
+ static void ConfigureSchedulerOptions(
+ IDurableTaskClientBuilder builder,
+ Action initialConfig,
+ Action? additionalConfig)
+ {
+ builder.Services.AddOptions(builder.Name)
+ .Configure(initialConfig)
+ .Configure(additionalConfig ?? (_ => { }))
+ .ValidateDataAnnotations();
+
+ builder.Services.TryAddEnumerable(
+ ServiceDescriptor.Singleton, ConfigureGrpcChannel>());
+ builder.UseGrpc(_ => { });
+ }
+
+ ///
+ /// Configuration class that sets up gRPC channels for client options
+ /// using the provided Durable Task Scheduler options.
+ ///
+ /// Monitor for accessing the current scheduler options configuration.
+ class ConfigureGrpcChannel(IOptionsMonitor schedulerOptions) :
+ IConfigureNamedOptions
+ {
+ ///
+ /// Configures the default named options instance.
+ ///
+ /// The options instance to configure.
+ public void Configure(GrpcDurableTaskClientOptions options) => this.Configure(Options.DefaultName, options);
+
+ ///
+ /// Configures a named options instance.
+ ///
+ /// The name of the options instance to configure.
+ /// The options instance to configure.
+ public void Configure(string? name, GrpcDurableTaskClientOptions options)
+ {
+ DurableTaskSchedulerClientOptions source = schedulerOptions.Get(name ?? Options.DefaultName);
+ options.Channel = source.CreateChannel();
+ }
+ }
+}
diff --git a/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs b/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs
new file mode 100644
index 00000000..94b33440
--- /dev/null
+++ b/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+using System.ComponentModel.DataAnnotations;
+using Azure.Core;
+using Azure.Identity;
+using Grpc.Core;
+using Grpc.Net.Client;
+
+namespace Microsoft.DurableTask;
+
+///
+/// Options for configuring the Durable Task Scheduler.
+///
+public class DurableTaskSchedulerClientOptions
+{
+ ///
+ /// Gets or sets the endpoint address of the Durable Task Scheduler resource.
+ /// Expected to be in the format "https://{scheduler-name}.{region}.durabletask.io".
+ ///
+ [Required(ErrorMessage = "Endpoint address is required")]
+ public string EndpointAddress { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the name of the task hub resource associated with the Durable Task Scheduler resource.
+ ///
+ [Required(ErrorMessage = "Task hub name is required")]
+ public string TaskHubName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the credential used to authenticate with the Durable Task Scheduler task hub resource.
+ ///
+ public TokenCredential? Credential { get; set; }
+
+ ///
+ /// Gets or sets the resource ID of the Durable Task Scheduler resource.
+ /// The default value is https://durabletask.io.
+ ///
+ public string ResourceId { get; set; } = "https://durabletask.io";
+
+ ///
+ /// Gets or sets a value indicating whether to allow insecure channel credentials.
+ /// This should only be set to true in local development/testing scenarios.
+ ///
+ public bool AllowInsecureCredentials { get; set; }
+
+ ///
+ /// Creates a new instance of from a connection string.
+ ///
+ /// The connection string to parse.
+ /// A new instance of .
+ public static DurableTaskSchedulerClientOptions FromConnectionString(string connectionString)
+ {
+ return FromConnectionString(new DurableTaskSchedulerConnectionString(connectionString));
+ }
+
+ ///
+ /// Creates a gRPC channel for communicating with the Durable Task Scheduler service.
+ ///
+ /// A configured instance that can be used to make gRPC calls.
+ internal GrpcChannel CreateChannel()
+ {
+ Verify.NotNull(this.EndpointAddress, nameof(this.EndpointAddress));
+ Verify.NotNull(this.TaskHubName, nameof(this.TaskHubName));
+ string taskHubName = this.TaskHubName;
+ string endpoint = !this.EndpointAddress.Contains("://")
+ ? $"https://{this.EndpointAddress}"
+ : this.EndpointAddress;
+ AccessTokenCache? cache =
+ this.Credential is not null
+ ? new AccessTokenCache(
+ this.Credential,
+ new TokenRequestContext(new[] { $"{this.ResourceId}/.default" }),
+ TimeSpan.FromMinutes(5))
+ : null;
+ CallCredentials managedBackendCreds = CallCredentials.FromInterceptor(
+ async (context, metadata) =>
+ {
+ metadata.Add("taskhub", taskHubName);
+ if (cache == null)
+ {
+ return;
+ }
+
+ AccessToken token = await cache.GetTokenAsync(context.CancellationToken);
+ metadata.Add("Authorization", $"Bearer {token.Token}");
+ });
+
+ // Production will use HTTPS, but local testing will use HTTP
+ ChannelCredentials channelCreds = endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ?
+ ChannelCredentials.SecureSsl :
+ ChannelCredentials.Insecure;
+ return GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
+ {
+ Credentials = ChannelCredentials.Create(channelCreds, managedBackendCreds),
+ UnsafeUseInsecureChannelCallCredentials = this.AllowInsecureCredentials,
+ });
+ }
+
+ ///
+ /// Creates a new instance of from a parsed connection string.
+ ///
+ /// The connection string to parse.
+ /// A new instance of .
+ internal static DurableTaskSchedulerClientOptions FromConnectionString(
+ DurableTaskSchedulerConnectionString connectionString) => new()
+ {
+ EndpointAddress = connectionString.Endpoint,
+ TaskHubName = connectionString.TaskHubName,
+ Credential = GetCredentialFromConnectionString(connectionString),
+ };
+
+ static TokenCredential? GetCredentialFromConnectionString(DurableTaskSchedulerConnectionString connectionString)
+ {
+ string authType = connectionString.Authentication;
+
+ // Parse the supported auth types, in a case-insensitive way and without spaces
+ switch (authType.ToLowerInvariant())
+ {
+ case "defaultazure":
+ return new DefaultAzureCredential();
+ case "managedidentity":
+ return new ManagedIdentityCredential(connectionString.ClientId);
+ case "workloadidentity":
+ var opts = new WorkloadIdentityCredentialOptions();
+ if (!string.IsNullOrEmpty(connectionString.ClientId))
+ {
+ opts.ClientId = connectionString.ClientId;
+ }
+
+ if (!string.IsNullOrEmpty(connectionString.TenantId))
+ {
+ opts.TenantId = connectionString.TenantId;
+ }
+
+ if (connectionString.AdditionallyAllowedTenants is not null)
+ {
+ foreach (string tenant in connectionString.AdditionallyAllowedTenants)
+ {
+ opts.AdditionallyAllowedTenants.Add(tenant);
+ }
+ }
+
+ return new WorkloadIdentityCredential(opts);
+ case "environment":
+ return new EnvironmentCredential();
+ case "azurecli":
+ return new AzureCliCredential();
+ case "azurepowershell":
+ return new AzurePowerShellCredential();
+ case "none":
+ return null;
+ default:
+ throw new ArgumentException(
+ $"The connection string contains an unsupported authentication type '{authType}'.",
+ nameof(connectionString));
+ }
+ }
+}
diff --git a/src/Shared/AzureManaged/AccessTokenCache.cs b/src/Shared/AzureManaged/AccessTokenCache.cs
new file mode 100644
index 00000000..54091f3c
--- /dev/null
+++ b/src/Shared/AzureManaged/AccessTokenCache.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+
+namespace Microsoft.DurableTask;
+
+///
+/// Caches and manages refresh for Azure access tokens.
+///
+sealed class AccessTokenCache
+{
+ readonly TokenCredential credential;
+ readonly TokenRequestContext context;
+ readonly TimeSpan margin;
+
+ AccessToken? token;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token credential to use for authentication.
+ /// The token request context.
+ /// The time margin to use for token refresh.
+ public AccessTokenCache(TokenCredential credential, TokenRequestContext context, TimeSpan margin)
+ {
+ this.credential = credential;
+ this.context = context;
+ this.margin = margin;
+ }
+
+ ///
+ /// Gets a token, either from cache or by requesting a new one if needed.
+ ///
+ /// A cancellation token.
+ /// An access token.
+ public async Task GetTokenAsync(CancellationToken cancellationToken)
+ {
+ DateTimeOffset nowWithMargin = DateTimeOffset.UtcNow.Add(this.margin);
+
+ if (this.token is null
+ || this.token.Value.RefreshOn < nowWithMargin
+ || this.token.Value.ExpiresOn < nowWithMargin)
+ {
+ this.token = await this.credential.GetTokenAsync(this.context, cancellationToken);
+ }
+
+ return this.token.Value;
+ }
+}
diff --git a/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs b/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs
new file mode 100644
index 00000000..66368a55
--- /dev/null
+++ b/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+using System.Data.Common;
+
+namespace Microsoft.DurableTask;
+
+///
+/// Represents the constituent parts of a connection string for a Durable Task Scheduler service.
+///
+sealed class DurableTaskSchedulerConnectionString
+{
+ readonly DbConnectionStringBuilder builder;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A connection string for a Durable Task Scheduler service.
+ public DurableTaskSchedulerConnectionString(string connectionString)
+ {
+ this.builder = new() { ConnectionString = connectionString };
+ }
+
+ ///
+ /// Gets the authentication method specified in the connection string (if any).
+ ///
+ public string Authentication => this.GetRequiredValue("Authentication");
+
+ ///
+ /// Gets the managed identity or workload identity client ID specified in the connection string (if any).
+ ///
+ public string? ClientId => this.GetValue("ClientID");
+
+ ///
+ /// Gets the "AdditionallyAllowedTenants" property, optionally used by Workload Identity.
+ /// Multiple values can be separated by a comma.
+ ///
+ public IList? AdditionallyAllowedTenants =>
+ string.IsNullOrEmpty(this.AdditionallyAllowedTenantsStr)
+ ? null
+ : this.AdditionallyAllowedTenantsStr!.Split(',');
+
+ ///
+ /// Gets the "TenantId" property, optionally used by Workload Identity.
+ ///
+ public string? TenantId => this.GetValue("TenantId");
+
+ ///
+ /// Gets the "TokenFilePath" property, optionally used by Workload Identity.
+ ///
+ public string? TokenFilePath => this.GetValue("TokenFilePath");
+
+ ///
+ /// Gets the endpoint specified in the connection string (if any).
+ ///
+ public string Endpoint => this.GetRequiredValue("Endpoint");
+
+ ///
+ /// Gets the task hub name specified in the connection string.
+ ///
+ public string TaskHubName => this.GetRequiredValue("TaskHub");
+
+ string? AdditionallyAllowedTenantsStr => this.GetValue("AdditionallyAllowedTenants");
+
+ string? GetValue(string name) =>
+ this.builder.TryGetValue(name, out object? value)
+ ? value as string
+ : null;
+
+ string GetRequiredValue(string name)
+ {
+ string? value = this.GetValue(name);
+ return Check.NotNullOrEmpty(value, name);
+ }
+}
diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs
new file mode 100644
index 00000000..2eec714c
--- /dev/null
+++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Microsoft.DurableTask.Worker.Grpc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.DurableTask.Worker.AzureManaged;
+
+///
+/// Extension methods for configuring Durable Task workers to use the Azure Durable Task Scheduler service.
+///
+public static class DurableTaskSchedulerWorkerExtensions
+{
+ ///
+ /// Configures Durable Task worker to use the Azure Durable Task Scheduler service.
+ ///
+ /// The Durable Task worker builder to configure.
+ /// The endpoint address of the Durable Task Scheduler resource. Expected to be in the format "https://{scheduler-name}.{region}.durabletask.io".
+ /// The name of the task hub resource associated with the Durable Task Scheduler resource.
+ /// The credential used to authenticate with the Durable Task Scheduler task hub resource.
+ /// Optional callback to dynamically configure DurableTaskSchedulerWorkerOptions.
+ public static void UseDurableTaskScheduler(
+ this IDurableTaskWorkerBuilder builder,
+ string endpointAddress,
+ string taskHubName,
+ TokenCredential credential,
+ Action? configure = null)
+ {
+ ConfigureSchedulerOptions(
+ builder,
+ options =>
+ {
+ options.EndpointAddress = endpointAddress;
+ options.TaskHubName = taskHubName;
+ options.Credential = credential;
+ },
+ configure);
+ }
+
+ ///
+ /// Configures Durable Task worker to use the Azure Durable Task Scheduler service using a connection string.
+ ///
+ /// The Durable Task worker builder to configure.
+ /// The connection string used to connect to the Durable Task Scheduler service.
+ /// Optional callback to dynamically configure DurableTaskSchedulerWorkerOptions.
+ public static void UseDurableTaskScheduler(
+ this IDurableTaskWorkerBuilder builder,
+ string connectionString,
+ Action? configure = null)
+ {
+ var connectionOptions = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+ ConfigureSchedulerOptions(
+ builder,
+ options =>
+ {
+ options.EndpointAddress = connectionOptions.EndpointAddress;
+ options.TaskHubName = connectionOptions.TaskHubName;
+ options.Credential = connectionOptions.Credential;
+ },
+ configure);
+ }
+
+ ///
+ /// Configures Durable Task worker to use the Azure Durable Task Scheduler service using configuration options.
+ ///
+ /// The Durable Task worker builder to configure.
+ /// Callback to configure DurableTaskSchedulerWorkerOptions.
+ public static void UseDurableTaskScheduler(
+ this IDurableTaskWorkerBuilder builder,
+ Action? configure = null)
+ {
+ ConfigureSchedulerOptions(builder, _ => { }, configure);
+ }
+
+ static void ConfigureSchedulerOptions(
+ IDurableTaskWorkerBuilder builder,
+ Action initialConfig,
+ Action? additionalConfig)
+ {
+ builder.Services.AddOptions(builder.Name)
+ .Configure(initialConfig)
+ .Configure(additionalConfig ?? (_ => { }))
+ .ValidateDataAnnotations();
+
+ builder.Services.TryAddEnumerable(
+ ServiceDescriptor.Singleton, ConfigureGrpcChannel>());
+ builder.UseGrpc(_ => { });
+ }
+
+ ///
+ /// Configuration class that sets up gRPC channels for worker options
+ /// using the provided Durable Task Scheduler options.
+ ///
+ /// Monitor for accessing the current scheduler options configuration.
+ class ConfigureGrpcChannel(IOptionsMonitor schedulerOptions) :
+ IConfigureNamedOptions
+ {
+ ///
+ /// Configures the default named options instance.
+ ///
+ /// The options instance to configure.
+ public void Configure(GrpcDurableTaskWorkerOptions options) => this.Configure(Options.DefaultName, options);
+
+ ///
+ /// Configures a named options instance.
+ ///
+ /// The name of the options instance to configure.
+ /// The options instance to configure.
+ public void Configure(string? name, GrpcDurableTaskWorkerOptions options)
+ {
+ DurableTaskSchedulerWorkerOptions source = schedulerOptions.Get(name ?? Options.DefaultName);
+ options.Channel = source.CreateChannel();
+ }
+ }
+}
diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs
new file mode 100644
index 00000000..8c05b5e6
--- /dev/null
+++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs
@@ -0,0 +1,165 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+using System.ComponentModel.DataAnnotations;
+using Azure.Core;
+using Azure.Identity;
+using Grpc.Core;
+using Grpc.Net.Client;
+
+namespace Microsoft.DurableTask;
+
+///
+/// Options for configuring the Durable Task Scheduler.
+///
+public class DurableTaskSchedulerWorkerOptions
+{
+ ///
+ /// Gets or sets the endpoint address of the Durable Task Scheduler resource.
+ /// Expected to be in the format "https://{scheduler-name}.{region}.durabletask.io".
+ ///
+ [Required(ErrorMessage = "Endpoint address is required")]
+ public string EndpointAddress { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the name of the task hub resource associated with the Durable Task Scheduler resource.
+ ///
+ [Required(ErrorMessage = "Task hub name is required")]
+ public string TaskHubName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the credential used to authenticate with the Durable Task Scheduler task hub resource.
+ ///
+ public TokenCredential? Credential { get; set; }
+
+ ///
+ /// Gets or sets the resource ID of the Durable Task Scheduler resource.
+ /// The default value is https://durabletask.io.
+ ///
+ public string ResourceId { get; set; } = "https://durabletask.io";
+
+ ///
+ /// Gets or sets the worker ID used to identify the worker instance.
+ /// The default value is a string containing the machine name, process ID, and a unique identifier.
+ ///
+ public string WorkerId { get; set; } = $"{Environment.MachineName},{Environment.ProcessId},{Guid.NewGuid():N}";
+
+ ///
+ /// Gets or sets a value indicating whether to allow insecure channel credentials.
+ /// This should only be set to true in local development/testing scenarios.
+ ///
+ public bool AllowInsecureCredentials { get; set; }
+
+ ///
+ /// Creates a new instance of from a connection string.
+ ///
+ /// The connection string to parse.
+ /// A new instance of .
+ public static DurableTaskSchedulerWorkerOptions FromConnectionString(string connectionString)
+ {
+ return FromConnectionString(new DurableTaskSchedulerConnectionString(connectionString));
+ }
+
+ ///
+ /// Creates a gRPC channel for communicating with the Durable Task Scheduler service.
+ ///
+ /// A configured instance that can be used to make gRPC calls.
+ internal GrpcChannel CreateChannel()
+ {
+ Verify.NotNull(this.EndpointAddress, nameof(this.EndpointAddress));
+ Verify.NotNull(this.TaskHubName, nameof(this.TaskHubName));
+ string taskHubName = this.TaskHubName;
+ string endpoint = !this.EndpointAddress.Contains("://")
+ ? $"https://{this.EndpointAddress}"
+ : this.EndpointAddress;
+ AccessTokenCache? cache =
+ this.Credential is not null
+ ? new AccessTokenCache(
+ this.Credential,
+ new TokenRequestContext(new[] { $"{this.ResourceId}/.default" }),
+ TimeSpan.FromMinutes(5))
+ : null;
+ CallCredentials managedBackendCreds = CallCredentials.FromInterceptor(
+ async (context, metadata) =>
+ {
+ metadata.Add("taskhub", taskHubName);
+ metadata.Add("workerid", this.WorkerId);
+ if (cache == null)
+ {
+ return;
+ }
+
+ AccessToken token = await cache.GetTokenAsync(context.CancellationToken);
+ metadata.Add("Authorization", $"Bearer {token.Token}");
+ });
+
+ // Production will use HTTPS, but local testing will use HTTP
+ ChannelCredentials channelCreds = endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ?
+ ChannelCredentials.SecureSsl :
+ ChannelCredentials.Insecure;
+ return GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
+ {
+ Credentials = ChannelCredentials.Create(channelCreds, managedBackendCreds),
+ UnsafeUseInsecureChannelCallCredentials = this.AllowInsecureCredentials,
+ });
+ }
+
+ ///
+ /// Creates a new instance of from a parsed connection string.
+ ///
+ /// The connection string to parse.
+ /// A new instance of .
+ internal static DurableTaskSchedulerWorkerOptions FromConnectionString(
+ DurableTaskSchedulerConnectionString connectionString) => new()
+ {
+ EndpointAddress = connectionString.Endpoint,
+ TaskHubName = connectionString.TaskHubName,
+ Credential = GetCredentialFromConnectionString(connectionString),
+ };
+
+ static TokenCredential? GetCredentialFromConnectionString(DurableTaskSchedulerConnectionString connectionString)
+ {
+ string authType = connectionString.Authentication;
+
+ // Parse the supported auth types, in a case-insensitive way and without spaces
+ switch (authType.ToLowerInvariant())
+ {
+ case "defaultazure":
+ return new DefaultAzureCredential();
+ case "managedidentity":
+ return new ManagedIdentityCredential(connectionString.ClientId);
+ case "workloadidentity":
+ var opts = new WorkloadIdentityCredentialOptions();
+ if (!string.IsNullOrEmpty(connectionString.ClientId))
+ {
+ opts.ClientId = connectionString.ClientId;
+ }
+
+ if (!string.IsNullOrEmpty(connectionString.TenantId))
+ {
+ opts.TenantId = connectionString.TenantId;
+ }
+
+ if (connectionString.AdditionallyAllowedTenants is not null)
+ {
+ foreach (string tenant in connectionString.AdditionallyAllowedTenants)
+ {
+ opts.AdditionallyAllowedTenants.Add(tenant);
+ }
+ }
+
+ return new WorkloadIdentityCredential(opts);
+ case "environment":
+ return new EnvironmentCredential();
+ case "azurecli":
+ return new AzureCliCredential();
+ case "azurepowershell":
+ return new AzurePowerShellCredential();
+ case "none":
+ return null;
+ default:
+ throw new ArgumentException(
+ $"The connection string contains an unsupported authentication type '{authType}'.",
+ nameof(connectionString));
+ }
+ }
+}
diff --git a/src/Worker/AzureManaged/Worker.AzureManaged.csproj b/src/Worker/AzureManaged/Worker.AzureManaged.csproj
new file mode 100644
index 00000000..9f3b877d
--- /dev/null
+++ b/src/Worker/AzureManaged/Worker.AzureManaged.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net6.0
+ Azure Managed extensions for the Durable Task Framework worker.
+ true
+ preview.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj
new file mode 100644
index 00000000..d7f4726c
--- /dev/null
+++ b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs
new file mode 100644
index 00000000..599fa1c7
--- /dev/null
+++ b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs
@@ -0,0 +1,170 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Azure.Identity;
+using FluentAssertions;
+using Microsoft.DurableTask.Client.Grpc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Microsoft.DurableTask.Client.AzureManaged.Tests;
+
+public class DurableTaskSchedulerClientExtensionsTests
+{
+ const string ValidEndpoint = "myaccount.westus3.durabletask.io";
+ const string ValidTaskHub = "testhub";
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithEndpointAndCredential_ShouldConfigureCorrectly()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ DefaultAzureCredential credential = new DefaultAzureCredential();
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential);
+
+ // Assert
+ ServiceProvider provider = services.BuildServiceProvider();
+ IOptions? options = provider.GetService>();
+ options.Should().NotBeNull();
+
+ // Validate the configured options
+ DurableTaskSchedulerClientOptions clientOptions = provider.GetRequiredService>().Value;
+ clientOptions.EndpointAddress.Should().Be(ValidEndpoint);
+ clientOptions.TaskHubName.Should().Be(ValidTaskHub);
+ clientOptions.Credential.Should().BeOfType();
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithConnectionString_ShouldConfigureCorrectly()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(connectionString);
+
+ // Assert
+ ServiceProvider provider = services.BuildServiceProvider();
+ IOptions? options = provider.GetService>();
+ options.Should().NotBeNull();
+
+ // Validate the configured options
+ DurableTaskSchedulerClientOptions clientOptions = provider.GetRequiredService>().Value;
+ clientOptions.EndpointAddress.Should().Be(ValidEndpoint);
+ clientOptions.TaskHubName.Should().Be(ValidTaskHub);
+ clientOptions.Credential.Should().BeOfType();
+ }
+
+ [Theory]
+ [InlineData(null, "testhub")]
+ [InlineData("myaccount.westus3.durabletask.io", null)]
+ public void UseDurableTaskScheduler_WithNullParameters_ShouldThrowOptionsValidationException(string endpoint, string taskHub)
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ DefaultAzureCredential credential = new DefaultAzureCredential();
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(endpoint, taskHub, credential);
+ ServiceProvider provider = services.BuildServiceProvider();
+
+ // Assert
+ var action = () => provider.GetRequiredService>().Value;
+ action.Should().Throw()
+ .WithMessage(endpoint == null
+ ? "DataAnnotation validation failed for 'DurableTaskSchedulerClientOptions' members: 'EndpointAddress' with the error: 'Endpoint address is required'."
+ : "DataAnnotation validation failed for 'DurableTaskSchedulerClientOptions' members: 'TaskHubName' with the error: 'Task hub name is required'.");
+ }
+
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithNullCredential_ShouldSucceed()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ TokenCredential? credential = null;
+
+ // Act & Assert
+ Action action = () => mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential!);
+ action.Should().NotThrow();
+
+ // Validate the configured options
+ ServiceProvider provider = services.BuildServiceProvider();
+ DurableTaskSchedulerClientOptions clientOptions = provider.GetRequiredService>().Value;
+ clientOptions.EndpointAddress.Should().Be(ValidEndpoint);
+ clientOptions.TaskHubName.Should().Be(ValidTaskHub);
+ clientOptions.Credential.Should().BeNull();
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithInvalidConnectionString_ShouldThrowArgumentException()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ string connectionString = "This is not a valid=connection string format";
+
+ // Act
+ Action action = () => mockBuilder.Object.UseDurableTaskScheduler(connectionString);
+
+ // Assert
+ action.Should().Throw()
+ .WithMessage("Value cannot be null. (Parameter 'Endpoint')");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void UseDurableTaskScheduler_WithNullOrEmptyConnectionString_ShouldThrowArgumentException(string connectionString)
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+
+ // Act & Assert
+ Action action = () => mockBuilder.Object.UseDurableTaskScheduler(connectionString);
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ mockBuilder.Setup(b => b.Name).Returns("CustomName");
+ DefaultAzureCredential credential = new DefaultAzureCredential();
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential);
+
+ // Assert
+ ServiceProvider provider = services.BuildServiceProvider();
+ IOptionsMonitor? optionsMonitor = provider.GetService>();
+ optionsMonitor.Should().NotBeNull();
+ DurableTaskSchedulerClientOptions options = optionsMonitor!.Get("CustomName");
+ options.Should().NotBeNull();
+ options.EndpointAddress.Should().Be(ValidEndpoint); // The https:// prefix is added by CreateChannel, not in the extension method
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ options.ResourceId.Should().Be("https://durabletask.io");
+ options.AllowInsecureCredentials.Should().BeFalse();
+ }
+}
diff --git a/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs
new file mode 100644
index 00000000..d0790c3f
--- /dev/null
+++ b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs
@@ -0,0 +1,210 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Identity;
+using FluentAssertions;
+using Xunit;
+
+namespace Microsoft.DurableTask.Shared.AzureManaged.Tests;
+
+public class DurableTaskSchedulerClientOptionsTests
+{
+ const string ValidEndpoint = "myaccount.westus3.durabletask.io";
+ const string ValidTaskHub = "testhub";
+
+ [Fact]
+ public void FromConnectionString_WithDefaultAzure_ShouldCreateValidInstance()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithManagedIdentity_ShouldCreateValidInstance()
+ {
+ // Arrange
+ const string clientId = "00000000-0000-0000-0000-000000000000";
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=ManagedIdentity;ClientID={clientId};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithWorkloadIdentity_ShouldCreateValidInstance()
+ {
+ // Arrange
+ const string clientId = "00000000-0000-0000-0000-000000000000";
+ const string tenantId = "11111111-1111-1111-1111-111111111111";
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=WorkloadIdentity;ClientID={clientId};TenantId={tenantId};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ }
+
+ [Theory]
+ [InlineData("Environment")]
+ [InlineData("AzureCLI")]
+ [InlineData("AzurePowerShell")]
+ public void FromConnectionString_WithValidAuthTypes_ShouldCreateValidInstance(string authType)
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication={authType};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithInvalidAuthType_ShouldThrowArgumentException()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=InvalidAuth;TaskHub={ValidTaskHub}";
+
+ // Act & Assert
+ Action action = () => DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+ action.Should().Throw()
+ .WithMessage("*contains an unsupported authentication type*");
+ }
+
+ [Fact]
+ public void FromConnectionString_WithMissingRequiredProperties_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure"; // Missing TaskHub
+
+ // Act & Assert
+ Action action = () => DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithNone_ShouldCreateInstanceWithNullCredential()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=None;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeNull();
+ }
+
+ [Fact]
+ public void DefaultProperties_ShouldHaveExpectedValues()
+ {
+ // Arrange & Act
+ DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions();
+
+ // Assert
+ options.ResourceId.Should().Be("https://durabletask.io");
+ options.AllowInsecureCredentials.Should().BeFalse();
+ }
+
+ [Fact]
+ public void CreateChannel_WithHttpsEndpoint_ShouldCreateSecureChannel()
+ {
+ // Arrange
+ DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions
+ {
+ EndpointAddress = $"https://{ValidEndpoint}",
+ TaskHubName = ValidTaskHub,
+ Credential = new DefaultAzureCredential()
+ };
+
+ // Act
+ using Grpc.Net.Client.GrpcChannel channel = options.CreateChannel();
+
+ // Assert
+ channel.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void CreateChannel_WithHttpEndpoint_ShouldCreateInsecureChannel()
+ {
+ // Arrange
+ DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions
+ {
+ EndpointAddress = $"http://{ValidEndpoint}",
+ TaskHubName = ValidTaskHub,
+ AllowInsecureCredentials = true
+ };
+
+ // Act
+ using Grpc.Net.Client.GrpcChannel channel = options.CreateChannel();
+
+ // Assert
+ channel.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithInvalidEndpoint_ShouldThrowArgumentException()
+ {
+ // Arrange
+ string connectionString = "Endpoint=not a valid endpoint;Authentication=DefaultAzure;TaskHub=testhub;";
+
+ // Act & Assert
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+ Action action = () => options.CreateChannel();
+ action.Should().Throw()
+ .WithMessage("Invalid URI: The hostname could not be parsed.");
+ }
+
+ [Fact]
+ public void FromConnectionString_WithoutProtocol_ShouldPreserveEndpoint()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerClientOptions options = DurableTaskSchedulerClientOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ }
+
+ [Fact]
+ public void CreateChannel_ShouldAddHttpsPrefix()
+ {
+ // Arrange
+ DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions
+ {
+ EndpointAddress = ValidEndpoint,
+ TaskHubName = ValidTaskHub,
+ Credential = new DefaultAzureCredential()
+ };
+
+ // Act
+ using Grpc.Net.Client.GrpcChannel channel = options.CreateChannel();
+
+ // Assert
+ channel.Should().NotBeNull();
+ // Note: We can't directly test the endpoint in the channel as it's not exposed
+ }
+}
diff --git a/test/Shared/AzureManaged.Tests/AccessTokenCacheTests.cs b/test/Shared/AzureManaged.Tests/AccessTokenCacheTests.cs
new file mode 100644
index 00000000..4ec0c825
--- /dev/null
+++ b/test/Shared/AzureManaged.Tests/AccessTokenCacheTests.cs
@@ -0,0 +1,119 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using FluentAssertions;
+using Moq;
+using Xunit;
+
+namespace Microsoft.DurableTask.Shared.AzureManaged.Tests;
+
+public class AccessTokenCacheTests
+{
+ readonly Mock mockCredential;
+ readonly TokenRequestContext tokenRequestContext;
+ readonly TimeSpan margin;
+ readonly CancellationToken cancellationToken;
+
+ public AccessTokenCacheTests()
+ {
+ this.mockCredential = new Mock();
+ this.tokenRequestContext = new TokenRequestContext(new[] { "https://durabletask.azure.com/.default" });
+ this.margin = TimeSpan.FromMinutes(5);
+ this.cancellationToken = CancellationToken.None;
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_WhenCalled_ShouldReturnToken()
+ {
+ // Arrange
+ AccessToken expectedToken = new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1));
+ this.mockCredential.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(expectedToken);
+ AccessTokenCache cache = new AccessTokenCache(this.mockCredential.Object, this.tokenRequestContext, this.margin);
+
+ // Act
+ AccessToken token = await cache.GetTokenAsync(this.cancellationToken);
+
+ // Assert
+ token.Should().Be(expectedToken);
+ this.mockCredential.Verify(c => c.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_WhenTokenExpired_ShouldRequestNewToken()
+ {
+ // Arrange
+ AccessToken expiredToken = new AccessToken("expired-token", DateTimeOffset.UtcNow.AddMinutes(-5));
+ AccessToken newToken = new AccessToken("new-token", DateTimeOffset.UtcNow.AddHours(1));
+ AccessTokenCache cache = new AccessTokenCache(this.mockCredential.Object, this.tokenRequestContext, this.margin);
+
+ this.mockCredential.SetupSequence(c => c.GetTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(expiredToken)
+ .ReturnsAsync(newToken);
+
+ // Act
+ AccessToken firstToken = await cache.GetTokenAsync(this.cancellationToken);
+ AccessToken secondToken = await cache.GetTokenAsync(this.cancellationToken);
+
+ // Assert
+ firstToken.Should().Be(expiredToken);
+ secondToken.Should().Be(newToken);
+ this.mockCredential.Verify(c => c.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_WhenTokenValid_ShouldReturnCachedToken()
+ {
+ // Arrange
+ AccessToken validToken = new AccessToken("valid-token", DateTimeOffset.UtcNow.AddHours(1));
+ this.mockCredential.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(validToken);
+ AccessTokenCache cache = new AccessTokenCache(this.mockCredential.Object, this.tokenRequestContext, this.margin);
+
+ // Act
+ AccessToken firstToken = await cache.GetTokenAsync(this.cancellationToken);
+ AccessToken secondToken = await cache.GetTokenAsync(this.cancellationToken);
+
+ // Assert
+ firstToken.Should().Be(validToken);
+ secondToken.Should().Be(validToken);
+ this.mockCredential.Verify(c => c.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Constructor_WithNullCredential_ShouldThrowNullReferenceException()
+ {
+ // Arrange
+ AccessTokenCache cache = new AccessTokenCache(null!, this.tokenRequestContext, this.margin);
+
+ // Act & Assert
+ // TODO: The constructor should validate its parameters and throw ArgumentNullException,
+ // but currently it allows null parameters and throws NullReferenceException when used.
+ Func action = () => cache.GetTokenAsync(this.cancellationToken);
+ await action.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetTokenAsync_WhenTokenNearExpiry_ShouldRequestNewToken()
+ {
+ // Arrange
+ DateTimeOffset expiryTime = DateTimeOffset.UtcNow.AddMinutes(10);
+ AccessToken nearExpiryToken = new AccessToken("near-expiry-token", expiryTime);
+ AccessToken newToken = new AccessToken("new-token", expiryTime.AddHours(1));
+ AccessTokenCache cache = new AccessTokenCache(this.mockCredential.Object, this.tokenRequestContext, TimeSpan.FromMinutes(15));
+
+ this.mockCredential.SetupSequence(c => c.GetTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(nearExpiryToken)
+ .ReturnsAsync(newToken);
+
+ // Act
+ AccessToken firstToken = await cache.GetTokenAsync(this.cancellationToken);
+ AccessToken secondToken = await cache.GetTokenAsync(this.cancellationToken);
+
+ // Assert
+ firstToken.Should().Be(nearExpiryToken);
+ secondToken.Should().Be(newToken);
+ this.mockCredential.Verify(c => c.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Exactly(2));
+ }
+}
diff --git a/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs b/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs
new file mode 100644
index 00000000..df2e80b5
--- /dev/null
+++ b/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs
@@ -0,0 +1,191 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Xunit;
+
+namespace Microsoft.DurableTask.Shared.AzureManaged.Tests;
+
+public class DurableTaskSchedulerConnectionStringTests
+{
+ const string ValidEndpoint = "myaccount.westus3.durabletask.io";
+ const string ValidTaskHub = "testhub";
+ const string ValidClientId = "00000000-0000-0000-0000-000000000000";
+ const string ValidTenantId = "11111111-1111-1111-1111-111111111111";
+
+ [Fact]
+ public void Constructor_WithValidConnectionString_ShouldParseCorrectly()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.Endpoint.Should().Be(ValidEndpoint);
+ parsedConnectionString.TaskHubName.Should().Be(ValidTaskHub);
+ parsedConnectionString.Authentication.Should().Be("DefaultAzure");
+ }
+
+ [Fact]
+ public void Constructor_WithManagedIdentity_ShouldParseClientId()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=ManagedIdentity;ClientID={ValidClientId};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.ClientId.Should().Be(ValidClientId);
+ }
+
+ [Fact]
+ public void Constructor_WithWorkloadIdentity_ShouldParseAllProperties()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=WorkloadIdentity;ClientID={ValidClientId};TenantId={ValidTenantId};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.ClientId.Should().Be(ValidClientId);
+ parsedConnectionString.TenantId.Should().Be(ValidTenantId);
+ }
+
+ [Fact]
+ public void Constructor_WithAdditionallyAllowedTenants_ShouldParseTenantList()
+ {
+ // Arrange
+ const string tenants = "tenant1,tenant2,tenant3";
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=WorkloadIdentity;AdditionallyAllowedTenants={tenants};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.AdditionallyAllowedTenants.Should().NotBeNull();
+ parsedConnectionString.AdditionallyAllowedTenants.Should().BeEquivalentTo(new[] { "tenant1", "tenant2", "tenant3" });
+ }
+
+ [Fact]
+ public void Constructor_WithMultipleAdditionallyAllowedTenants_ShouldParseCorrectly()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=WorkloadIdentity;TaskHub={ValidTaskHub};" +
+ "AdditionallyAllowedTenants=tenant1,tenant2,tenant3";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.AdditionallyAllowedTenants.Should().NotBeNull();
+ parsedConnectionString.AdditionallyAllowedTenants!.Should().HaveCount(3);
+ parsedConnectionString.AdditionallyAllowedTenants.Should().Contain(new[] { "tenant1", "tenant2", "tenant3" });
+ }
+
+ [Fact]
+ public void Constructor_WithCaseInsensitivePropertyNames_ShouldParseCorrectly()
+ {
+ // Arrange
+ string connectionString = $"endpoint={ValidEndpoint};AUTHENTICATION=DefaultAzure;taskhub={ValidTaskHub};" +
+ $"clientid={ValidClientId};tenantid={ValidTenantId}";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.Endpoint.Should().Be(ValidEndpoint);
+ parsedConnectionString.Authentication.Should().Be("DefaultAzure");
+ parsedConnectionString.TaskHubName.Should().Be(ValidTaskHub);
+ parsedConnectionString.ClientId.Should().Be(ValidClientId);
+ parsedConnectionString.TenantId.Should().Be(ValidTenantId);
+ }
+
+ [Fact]
+ public void Constructor_WithInvalidConnectionStringFormat_ShouldThrowFormatException()
+ {
+ // Arrange
+ string connectionString = "This is not a valid=connection string format";
+
+ // Act & Assert
+ Action action = () => _ = new DurableTaskSchedulerConnectionString(connectionString).Endpoint;
+ action.Should().Throw()
+ .WithMessage("Value cannot be null. (Parameter 'Endpoint')");
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyConnectionString_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ string connectionString = string.Empty;
+
+ // Act & Assert
+ Action action = () => _ = new DurableTaskSchedulerConnectionString(connectionString).Endpoint;
+ action.Should().Throw()
+ .WithMessage("Value cannot be null. (Parameter 'Endpoint')");
+ }
+
+ [Fact]
+ public void Constructor_WithDuplicateKeys_ShouldUseLastValue()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub=hub1;TaskHub=hub2";
+
+ // Act
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.TaskHubName.Should().Be("hub2");
+ }
+
+ [Fact]
+ public void Constructor_WithMissingRequiredProperties_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ string connectionString = $"Authentication=DefaultAzure;TaskHub={ValidTaskHub}"; // Missing Endpoint
+
+ // Act & Assert
+ Action action = () => _ = new DurableTaskSchedulerConnectionString(connectionString).Endpoint;
+ action.Should().Throw()
+ .WithMessage("Value cannot be null. (Parameter 'Endpoint')");
+ }
+
+ [Fact]
+ public void Constructor_WithInvalidConnectionString_ShouldThrowArgumentException()
+ {
+ // Arrange
+ string connectionString = "This is not a valid connection string";
+
+ // Act & Assert
+ Action action = () => new DurableTaskSchedulerConnectionString(connectionString);
+ action.Should().Throw()
+ .WithMessage("*Format of the initialization string does not conform to specification*");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void Constructor_WithNullOrEmptyConnectionString_ShouldThrowArgumentNullException(string? connectionString)
+ {
+ // Act & Assert
+ Action action = () => _ = new DurableTaskSchedulerConnectionString(connectionString!).Endpoint;
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void GetValue_WithNonExistentProperty_ShouldReturnNull()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+ DurableTaskSchedulerConnectionString parsedConnectionString = new DurableTaskSchedulerConnectionString(connectionString);
+
+ // Assert
+ parsedConnectionString.ClientId.Should().BeNull();
+ parsedConnectionString.TenantId.Should().BeNull();
+ parsedConnectionString.TokenFilePath.Should().BeNull();
+ parsedConnectionString.AdditionallyAllowedTenants.Should().BeNull();
+ }
+}
diff --git a/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj b/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj
new file mode 100644
index 00000000..a30522af
--- /dev/null
+++ b/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs
new file mode 100644
index 00000000..bec5a962
--- /dev/null
+++ b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs
@@ -0,0 +1,175 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Azure.Identity;
+using FluentAssertions;
+using Microsoft.DurableTask.Worker.Grpc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Microsoft.DurableTask.Worker.AzureManaged.Tests;
+
+public class DurableTaskSchedulerWorkerExtensionsTests
+{
+ const string ValidEndpoint = "myaccount.westus3.durabletask.io";
+ const string ValidTaskHub = "testhub";
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithEndpointAndCredential_ShouldConfigureCorrectly()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ DefaultAzureCredential credential = new DefaultAzureCredential();
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential);
+
+ // Assert
+ ServiceProvider provider = services.BuildServiceProvider();
+ IOptions? options = provider.GetService>();
+ options.Should().NotBeNull();
+
+ // Validate the configured options
+ var workerOptions = provider.GetRequiredService>().Value;
+ workerOptions.EndpointAddress.Should().Be(ValidEndpoint);
+ workerOptions.TaskHubName.Should().Be(ValidTaskHub);
+ workerOptions.Credential.Should().BeOfType();
+ workerOptions.ResourceId.Should().Be("https://durabletask.io");
+ workerOptions.AllowInsecureCredentials.Should().BeFalse();
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithConnectionString_ShouldConfigureCorrectly()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(connectionString);
+
+ // Assert
+ ServiceProvider provider = services.BuildServiceProvider();
+ IOptions? options = provider.GetService>();
+ options.Should().NotBeNull();
+
+ // Validate the configured options
+ var workerOptions = provider.GetRequiredService>().Value;
+ workerOptions.EndpointAddress.Should().Be(ValidEndpoint);
+ workerOptions.TaskHubName.Should().Be(ValidTaskHub);
+ workerOptions.Credential.Should().BeOfType();
+ workerOptions.ResourceId.Should().Be("https://durabletask.io");
+ workerOptions.AllowInsecureCredentials.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData(null, "testhub")]
+ [InlineData("myaccount.westus3.durabletask.io", null)]
+ public void UseDurableTaskScheduler_WithNullParameters_ShouldThrowOptionsValidationException(string endpoint, string taskHub)
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ DefaultAzureCredential credential = new DefaultAzureCredential();
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(endpoint, taskHub, credential);
+ ServiceProvider provider = services.BuildServiceProvider();
+
+ // Assert
+ var action = () => provider.GetRequiredService>().Value;
+ action.Should().Throw()
+ .WithMessage(endpoint == null
+ ? "DataAnnotation validation failed for 'DurableTaskSchedulerWorkerOptions' members: 'EndpointAddress' with the error: 'Endpoint address is required'."
+ : "DataAnnotation validation failed for 'DurableTaskSchedulerWorkerOptions' members: 'TaskHubName' with the error: 'Task hub name is required'.");
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithNullCredential_ShouldSucceed()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ TokenCredential? credential = null;
+
+ // Act & Assert
+ Action action = () => mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential!);
+ action.Should().NotThrow();
+
+ // Validate the configured options
+ ServiceProvider provider = services.BuildServiceProvider();
+ var workerOptions = provider.GetRequiredService>().Value;
+ workerOptions.EndpointAddress.Should().Be(ValidEndpoint);
+ workerOptions.TaskHubName.Should().Be(ValidTaskHub);
+ workerOptions.Credential.Should().BeNull();
+ workerOptions.ResourceId.Should().Be("https://durabletask.io");
+ workerOptions.AllowInsecureCredentials.Should().BeFalse();
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithInvalidConnectionString_ShouldThrowArgumentException()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ string connectionString = "This is not a valid=connection string format";
+
+ // Act
+ Action action = () => mockBuilder.Object.UseDurableTaskScheduler(connectionString);
+
+ // Assert
+ action.Should().Throw()
+ .WithMessage("Value cannot be null. (Parameter 'Endpoint')");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void UseDurableTaskScheduler_WithNullOrEmptyConnectionString_ShouldThrowArgumentException(string connectionString)
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+
+ // Act & Assert
+ Action action = () => mockBuilder.Object.UseDurableTaskScheduler(connectionString);
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly()
+ {
+ // Arrange
+ ServiceCollection services = new ServiceCollection();
+ Mock mockBuilder = new Mock();
+ mockBuilder.Setup(b => b.Services).Returns(services);
+ mockBuilder.Setup(b => b.Name).Returns("CustomName");
+ DefaultAzureCredential credential = new DefaultAzureCredential();
+
+ // Act
+ mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential);
+
+ // Assert
+ ServiceProvider provider = services.BuildServiceProvider();
+ IOptionsMonitor? optionsMonitor = provider.GetService>();
+ optionsMonitor.Should().NotBeNull();
+ DurableTaskSchedulerWorkerOptions options = optionsMonitor!.Get("CustomName");
+ options.Should().NotBeNull();
+ options.EndpointAddress.Should().Be(ValidEndpoint); // The https:// prefix is added by CreateChannel, not in the extension method
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ options.ResourceId.Should().Be("https://durabletask.io");
+ options.AllowInsecureCredentials.Should().BeFalse();
+ }
+}
diff --git a/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs
new file mode 100644
index 00000000..03150b43
--- /dev/null
+++ b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs
@@ -0,0 +1,213 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Identity;
+using FluentAssertions;
+using Xunit;
+
+namespace Microsoft.DurableTask.Shared.AzureManaged.Tests;
+
+public class DurableTaskSchedulerWorkerOptionsTests
+{
+ const string ValidEndpoint = "myaccount.westus3.durabletask.io";
+ const string ValidTaskHub = "testhub";
+
+ [Fact]
+ public void FromConnectionString_WithDefaultAzure_ShouldCreateValidInstance()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithManagedIdentity_ShouldCreateValidInstance()
+ {
+ // Arrange
+ const string clientId = "00000000-0000-0000-0000-000000000000";
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=ManagedIdentity;ClientID={clientId};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithWorkloadIdentity_ShouldCreateValidInstance()
+ {
+ // Arrange
+ const string clientId = "00000000-0000-0000-0000-000000000000";
+ const string tenantId = "11111111-1111-1111-1111-111111111111";
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=WorkloadIdentity;ClientID={clientId};TenantId={tenantId};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeOfType();
+ }
+
+ [Theory]
+ [InlineData("Environment")]
+ [InlineData("AzureCLI")]
+ [InlineData("AzurePowerShell")]
+ public void FromConnectionString_WithValidAuthTypes_ShouldCreateValidInstance(string authType)
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication={authType};TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithInvalidAuthType_ShouldThrowArgumentException()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=InvalidAuth;TaskHub={ValidTaskHub}";
+
+ // Act & Assert
+ Action action = () => DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+ action.Should().Throw()
+ .WithMessage("*contains an unsupported authentication type*");
+ }
+
+ [Fact]
+ public void FromConnectionString_WithMissingRequiredProperties_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure"; // Missing TaskHub
+
+ // Act & Assert
+ Action action = () => DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithNone_ShouldCreateInstanceWithNullCredential()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=None;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ options.TaskHubName.Should().Be(ValidTaskHub);
+ options.Credential.Should().BeNull();
+ }
+
+ [Fact]
+ public void DefaultProperties_ShouldHaveExpectedValues()
+ {
+ // Arrange & Act
+ DurableTaskSchedulerWorkerOptions options = new DurableTaskSchedulerWorkerOptions();
+
+ // Assert
+ options.ResourceId.Should().Be("https://durabletask.io");
+ options.WorkerId.Should().NotBeNullOrEmpty();
+ options.WorkerId.Should().Contain(Environment.MachineName);
+ options.WorkerId.Should().Contain(Environment.ProcessId.ToString());
+ options.AllowInsecureCredentials.Should().BeFalse();
+ }
+
+ [Fact]
+ public void CreateChannel_WithHttpsEndpoint_ShouldCreateSecureChannel()
+ {
+ // Arrange
+ DurableTaskSchedulerWorkerOptions options = new DurableTaskSchedulerWorkerOptions
+ {
+ EndpointAddress = $"https://{ValidEndpoint}",
+ TaskHubName = ValidTaskHub,
+ Credential = new DefaultAzureCredential()
+ };
+
+ // Act
+ using Grpc.Net.Client.GrpcChannel channel = options.CreateChannel();
+
+ // Assert
+ channel.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void CreateChannel_WithHttpEndpoint_ShouldCreateInsecureChannel()
+ {
+ // Arrange
+ DurableTaskSchedulerWorkerOptions options = new DurableTaskSchedulerWorkerOptions
+ {
+ EndpointAddress = $"http://{ValidEndpoint}",
+ TaskHubName = ValidTaskHub,
+ AllowInsecureCredentials = true
+ };
+
+ // Act
+ using Grpc.Net.Client.GrpcChannel channel = options.CreateChannel();
+
+ // Assert
+ channel.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void FromConnectionString_WithInvalidEndpoint_ShouldThrowArgumentException()
+ {
+ // Arrange
+ string connectionString = "Endpoint=not a valid endpoint;Authentication=DefaultAzure;TaskHub=testhub;";
+
+ // Act & Assert
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+ Action action = () => options.CreateChannel();
+ action.Should().Throw()
+ .WithMessage("Invalid URI: The hostname could not be parsed.");
+ }
+
+ [Fact]
+ public void FromConnectionString_WithoutProtocol_ShouldPreserveEndpoint()
+ {
+ // Arrange
+ string connectionString = $"Endpoint={ValidEndpoint};Authentication=DefaultAzure;TaskHub={ValidTaskHub}";
+
+ // Act
+ DurableTaskSchedulerWorkerOptions options = DurableTaskSchedulerWorkerOptions.FromConnectionString(connectionString);
+
+ // Assert
+ options.EndpointAddress.Should().Be(ValidEndpoint);
+ }
+
+ [Fact]
+ public void CreateChannel_ShouldAddHttpsPrefix()
+ {
+ // Arrange
+ DurableTaskSchedulerWorkerOptions options = new DurableTaskSchedulerWorkerOptions
+ {
+ EndpointAddress = ValidEndpoint,
+ TaskHubName = ValidTaskHub,
+ Credential = new DefaultAzureCredential()
+ };
+
+ // Act
+ using Grpc.Net.Client.GrpcChannel channel = options.CreateChannel();
+
+ // Assert
+ channel.Should().NotBeNull();
+ // Note: We can't directly test the endpoint in the channel as it's not exposed
+ }
+}
diff --git a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj
new file mode 100644
index 00000000..c22ad3f9
--- /dev/null
+++ b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+