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 + + + + + + + + + + + + + + +