diff --git a/Aspire.slnx b/Aspire.slnx index df9a7ed337f..dac97a2e18a 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -22,6 +22,7 @@ + @@ -147,6 +148,10 @@ + + + + @@ -436,6 +441,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index a3b5f9ab58b..2fc9e58c37e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -177,6 +177,9 @@ + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs index ff0e39fb7be..88c42816783 100644 --- a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs @@ -8,6 +8,7 @@ builder.AddAzureFunctionsProject("funcapp") .WithHostStorage(storage) - .WithReference(taskHub); + .WithReference(taskHub) + .WaitFor(taskHub); builder.Build().Run(); diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/DurableTaskWorkerWithDts.AppHost.csproj b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/DurableTaskWorkerWithDts.AppHost.csproj new file mode 100644 index 00000000000..7d48527c7eb --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/DurableTaskWorkerWithDts.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/Program.cs b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/Program.cs new file mode 100644 index 00000000000..1305dbcb6b1 --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/Program.cs @@ -0,0 +1,12 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var scheduler = builder.AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + +var taskHub = scheduler.AddTaskHub("taskhub"); + +builder.AddProject("worker") + .WithReference(taskHub) + .WaitFor(taskHub); + +builder.Build().Run(); diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/Properties/launchSettings.json b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..05d4ad302dd --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.AppHost/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17245;http://localhost:15055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21004", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22111" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19011", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20126" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:15889", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16176" + } + } + } +} diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/ChainingOrchestrator.cs b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/ChainingOrchestrator.cs new file mode 100644 index 00000000000..f2ff638a820 --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/ChainingOrchestrator.cs @@ -0,0 +1,20 @@ +using Microsoft.DurableTask; + +public class ChainingOrchestrator : TaskOrchestrator> +{ + public override async Task> RunAsync(TaskOrchestrationContext context, object? input) + { + ILogger logger = context.CreateReplaySafeLogger(); + logger.LogInformation("Saying hello."); + + var outputs = new List + { + await context.CallActivityAsync(nameof(SayHelloActivity), "Tokyo"), + await context.CallActivityAsync(nameof(SayHelloActivity), "Seattle"), + await context.CallActivityAsync(nameof(SayHelloActivity), "London") + }; + + // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + return outputs; + } +} diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/DurableTaskWorkerWithDts.Worker.csproj b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/DurableTaskWorkerWithDts.Worker.csproj new file mode 100644 index 00000000000..107e7dab251 --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/DurableTaskWorkerWithDts.Worker.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + + + + diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/Program.cs b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/Program.cs new file mode 100644 index 00000000000..23c9b17360e --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.DurableTask.Worker; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddDurableTaskSchedulerWorker("taskhub", worker => +{ + worker.AddTasks(r => + { + r.AddOrchestrator(); + r.AddActivity(); + }); +}); + +var host = builder.Build(); +host.Run(); diff --git a/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/SayHelloActivity.cs b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/SayHelloActivity.cs new file mode 100644 index 00000000000..81a17690e53 --- /dev/null +++ b/playground/DurableTaskWorkerWithDts/DurableTaskWorkerWithDts.Worker/SayHelloActivity.cs @@ -0,0 +1,17 @@ +using Microsoft.DurableTask; + +public class SayHelloActivity : TaskActivity +{ + private readonly ILogger _logger; + + public SayHelloActivity(ILogger logger) + { + _logger = logger; + } + + public override Task RunAsync(TaskActivityContext context, string input) + { + _logger.LogInformation("Saying hello to {Name}", input); + return Task.FromResult($"Hello {input}!"); + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 3f416538c3e..343db7b1522 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -112,6 +112,12 @@ internal static IResourceBuilder RunAsExistingCore /// The resource builder for the scheduler. /// Callback that exposes underlying container used for emulation to allow for customization. /// The same instance for chaining. + /// + /// The Durable Task Scheduler emulator stores all orchestration and entity state in memory. + /// State is lost when the container is stopped. To preserve the container (and its in-memory state) + /// across application restarts, use + /// with in the callback. + /// /// /// Run the scheduler locally using the emulator: /// @@ -120,6 +126,14 @@ internal static IResourceBuilder RunAsExistingCore /// .RunAsEmulator(); /// /// + /// + /// Persist the emulator container to preserve in-memory state across application restarts: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(c => c.WithLifetime(ContainerLifetime.Persistent)); + /// + /// [AspireExport(Description = "Configures the Durable Task scheduler to run using the local emulator.", RunSyncOnBackgroundThread = true)] public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { @@ -136,6 +150,7 @@ public static IResourceBuilder RunAsEmulator(this builder.WithHttpEndpoint(name: "grpc", targetPort: 8080) .WithEndpoint("grpc", endpoint => endpoint.Transport = "http2") .WithHttpEndpoint(name: "http", targetPort: 8081) + .WithHttpHealthCheck(endpointName: "http", path: "/healthz", statusCode: 204) .WithHttpEndpoint(name: "dashboard", targetPort: 8082) .WithUrlForEndpoint("dashboard", c => c.DisplayText = "Scheduler Dashboard") .WithAnnotation(new ContainerImageAnnotation @@ -281,4 +296,79 @@ internal static IResourceBuilder WithTaskHubNameCore( IResourceBuilder parameter => builder.WithTaskHubName(parameter), _ => throw new ArgumentException($"Unexpected task hub name type: {taskHubName.GetType().Name}", nameof(taskHubName)) }; + + /// + /// Modifies the host port that the Durable Task Scheduler emulator listens on for gRPC requests. + /// + /// The emulator resource builder. + /// Host port to use. + /// A reference to the for the emulator. + /// + /// Set the gRPC host port: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(c => c.WithGrpcPort(18080)); + /// + /// + [AspireExport(Description = "Sets the host port for gRPC requests on the Durable Task Scheduler emulator")] + public static IResourceBuilder WithGrpcPort(this IResourceBuilder builder, int port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint("grpc", endpoint => + { + endpoint.Port = port; + }); + } + + /// + /// Modifies the host port that the Durable Task Scheduler emulator listens on for HTTP requests. + /// + /// The emulator resource builder. + /// Host port to use. + /// A reference to the for the emulator. + /// + /// Set the HTTP host port: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(c => c.WithHttpPort(18081)); + /// + /// + [AspireExport(Description = "Sets the host port for HTTP requests on the Durable Task Scheduler emulator")] + public static IResourceBuilder WithHttpPort(this IResourceBuilder builder, int port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint("http", endpoint => + { + endpoint.Port = port; + }); + } + + /// + /// Modifies the host port that the Durable Task Scheduler emulator listens on for dashboard requests. + /// + /// The emulator resource builder. + /// Host port to use. + /// A reference to the for the emulator. + /// + /// Set the dashboard host port: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(c => c.WithDashboardPort(18082)); + /// + /// + [AspireExport(Description = "Sets the host port for dashboard requests on the Durable Task Scheduler emulator")] + public static IResourceBuilder WithDashboardPort(this IResourceBuilder builder, int port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint("dashboard", endpoint => + { + endpoint.Port = port; + }); + } } diff --git a/src/Aspire.Hosting.Azure.Functions/README.md b/src/Aspire.Hosting.Azure.Functions/README.md index 86ef7ff7cec..dd1d5ff5c8d 100644 --- a/src/Aspire.Hosting.Azure.Functions/README.md +++ b/src/Aspire.Hosting.Azure.Functions/README.md @@ -84,6 +84,15 @@ When a Scheduler runs as an emulator, Aspire automatically provides: - A "Task Hub Dashboard" URL for each Task Hub resource. - A `DTS_TASK_HUB_NAMES` environment variable on the emulator container listing the Task Hub names associated with that scheduler. +> **Note:** The DTS emulator stores all orchestration and entity state in memory. State is lost when the +> container is stopped. The emulator does not support volume-based persistence. To preserve in-memory state +> across application restarts, configure the container with a persistent lifetime: +> +> ```csharp +> var scheduler = builder.AddDurableTaskScheduler("scheduler") +> .RunAsEmulator(c => c.WithLifetime(ContainerLifetime.Persistent)); +> ``` + ### Use an existing Scheduler If you already have a Scheduler instance, configure the resource using its connection string: diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/Aspire.Microsoft.DurableTask.AzureManaged.csproj b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/Aspire.Microsoft.DurableTask.AzureManaged.csproj new file mode 100644 index 00000000000..b97b3b5c214 --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/Aspire.Microsoft.DurableTask.AzureManaged.csproj @@ -0,0 +1,31 @@ + + + + $(AllTargetFrameworks) + true + $(ComponentCommonPackageTags) durabletask durable-task orchestration workflow + A Durable Task Scheduler client that integrates with Aspire, including health checks, logging, and telemetry. + + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/AspireDurableTaskSchedulerExtensions.cs b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/AspireDurableTaskSchedulerExtensions.cs new file mode 100644 index 00000000000..2fd81ced354 --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/AspireDurableTaskSchedulerExtensions.cs @@ -0,0 +1,301 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Microsoft.DurableTask.AzureManaged; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for connecting to a Durable Task Scheduler with Aspire. +/// +/// +/// Durable Task Scheduler documentation +public static class AspireDurableTaskSchedulerExtensions +{ + private const string DefaultConfigSectionName = + "Aspire:Microsoft:DurableTask:AzureManaged"; + + /// + /// Registers a Durable Task worker (and optionally a client) connected to a + /// Durable Task Scheduler. Configures health checks and telemetry. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// A delegate to configure the (e.g. to register orchestrators and activities). + /// An optional delegate to customize . Invoked after settings are read from configuration. + /// Whether to also register a . Defaults to . + /// + /// Reads the configuration from the Aspire:Microsoft:DurableTask:AzureManaged section. + /// The connection string is retrieved from the ConnectionStrings configuration section using as the key. + /// + /// + /// Register a Durable Task worker with orchestrators and activities: + /// + /// builder.AddDurableTaskSchedulerWorker("scheduler", worker => + /// { + /// worker.AddTasks(tasks => + /// { + /// tasks.AddOrchestrator<MyOrchestrator>(); + /// tasks.AddActivity<MyActivity>(); + /// }); + /// }); + /// + /// + /// Thrown when or is . + /// Thrown when the connection string is not found. + /// + /// + public static void AddDurableTaskSchedulerWorker( + this IHostApplicationBuilder builder, + string connectionName, + Action configureWorker, + Action? configureSettings = null, + bool includeClient = true) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + ArgumentNullException.ThrowIfNull(configureWorker); + + var settings = ReadSettings(builder, connectionName, configureSettings); + + builder.Services.AddDurableTaskWorker(b => + { + b.UseDurableTaskScheduler(settings.ConnectionString!); + configureWorker(b); + }); + + if (includeClient) + { + builder.Services.AddDurableTaskClient(b => + { + b.UseDurableTaskScheduler(settings.ConnectionString!); + }); + } + + ConfigureObservability(builder, settings, connectionName); + } + + /// + /// Registers a keyed Durable Task worker (and optionally a client) connected to + /// a Durable Task Scheduler. Configures health checks and telemetry. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the and also to retrieve the connection string from the ConnectionStrings configuration section. + /// A delegate to configure the . + /// An optional delegate to customize . + /// Whether to also register a keyed . Defaults to . + /// + /// Reads the configuration from the Aspire:Microsoft:DurableTask:AzureManaged:{name} section, + /// falling back to the Aspire:Microsoft:DurableTask:AzureManaged section. + /// The connection string is retrieved from the ConnectionStrings configuration section using as the key. + /// + /// + /// Register a keyed Durable Task worker: + /// + /// builder.AddKeyedDurableTaskSchedulerWorker("my-worker", worker => + /// { + /// worker.AddTasks(tasks => + /// { + /// tasks.AddOrchestrator<MyOrchestrator>(); + /// tasks.AddActivity<MyActivity>(); + /// }); + /// }); + /// + /// + /// Thrown when or is . + /// Thrown if mandatory is empty. + /// Thrown when the connection string is not found. + /// + /// + public static void AddKeyedDurableTaskSchedulerWorker( + this IHostApplicationBuilder builder, + string name, + Action configureWorker, + Action? configureSettings = null, + bool includeClient = true) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(configureWorker); + + var settings = ReadSettings(builder, name, configureSettings); + + builder.Services.AddDurableTaskWorker(name, b => + { + b.UseDurableTaskScheduler(settings.ConnectionString!); + configureWorker(b); + }); + + if (includeClient) + { + builder.Services.AddDurableTaskClient(name, b => + { + b.UseDurableTaskScheduler(settings.ConnectionString!); + }); + } + + ConfigureObservability(builder, settings, name); + } + + /// + /// Registers a connected to a Durable Task Scheduler. + /// Configures health checks and telemetry. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate to customize . + /// + /// + /// Use this method when you only need to start and manage orchestrations (e.g. schedule new instances, + /// query status, or send events) without hosting a worker. If you also need to run orchestrators and + /// activities, use instead, which registers both a worker + /// and a client by default. + /// + /// + /// Reads the configuration from the Aspire:Microsoft:DurableTask:AzureManaged section. + /// The connection string is retrieved from the ConnectionStrings configuration section using as the key. + /// + /// + /// + /// Register a Durable Task client to start orchestrations from an API controller: + /// + /// builder.AddDurableTaskSchedulerClient("scheduler"); + /// + /// + /// Thrown when is . + /// Thrown when the connection string is not found. + /// + /// + public static void AddDurableTaskSchedulerClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + var settings = ReadSettings(builder, connectionName, configureSettings); + + builder.Services.AddDurableTaskClient(b => + { + b.UseDurableTaskScheduler(settings.ConnectionString!); + }); + + ConfigureObservability(builder, settings, connectionName); + } + + /// + /// Registers a keyed connected to a Durable Task Scheduler. + /// Configures health checks and telemetry. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate to customize . + /// + /// Reads the configuration from the Aspire:Microsoft:DurableTask:AzureManaged:{name} section, + /// falling back to the Aspire:Microsoft:DurableTask:AzureManaged section. + /// The connection string is retrieved from the ConnectionStrings configuration section using as the key. + /// + /// + /// Register a keyed Durable Task client: + /// + /// builder.AddKeyedDurableTaskSchedulerClient("my-client"); + /// + /// + /// Thrown when is . + /// Thrown if mandatory is empty. + /// Thrown when the connection string is not found. + /// + /// + public static void AddKeyedDurableTaskSchedulerClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var settings = ReadSettings(builder, name, configureSettings); + + builder.Services.AddDurableTaskClient(name, b => + { + b.UseDurableTaskScheduler(settings.ConnectionString!); + }); + + ConfigureObservability(builder, settings, name); + } + + private static DurableTaskSchedulerSettings ReadSettings( + IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings) + { + var settings = new DurableTaskSchedulerSettings(); + var configSection = builder.Configuration.GetSection(DefaultConfigSectionName); + var namedConfigSection = configSection.GetSection(connectionName); + configSection.Bind(settings); + namedConfigSection.Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + ConnectionStringValidation.ValidateConnectionString( + settings.ConnectionString, connectionName, DefaultConfigSectionName, $"{DefaultConfigSectionName}:{connectionName}"); + + return settings; + } + + private static void ConfigureObservability( + IHostApplicationBuilder builder, + DurableTaskSchedulerSettings settings, + string connectionName) + { + if (!settings.DisableHealthChecks) + { + var connectionString = settings.ConnectionString!; + var endpoint = DurableTaskSchedulerConnectionString.GetEndpoint(connectionString); + var taskHubName = DurableTaskSchedulerConnectionString.GetTaskHubName(connectionString); + + if (endpoint is null) + { + throw new InvalidOperationException( + $"Health checks are enabled for Durable Task Scheduler connection '{connectionName}', but the connection string is missing the 'Endpoint' value."); + } + + if (taskHubName is null) + { + throw new InvalidOperationException( + $"Health checks are enabled for Durable Task Scheduler connection '{connectionName}', but the connection string is missing the 'TaskHub' value."); + } + + var healthCheckName = $"DurableTaskScheduler_{connectionName}"; + + builder.TryAddHealthCheck(new HealthCheckRegistration( + healthCheckName, + _ => new DurableTaskSchedulerHealthCheck(endpoint, taskHubName), + failureStatus: default, + tags: default)); + } + + if (!settings.DisableTracing) + { + builder.Services.AddOpenTelemetry() + .WithTracing(t => + { + t.AddSource("Microsoft.DurableTask"); + }); + } + } +} diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/AssemblyInfo.cs b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/AssemblyInfo.cs new file mode 100644 index 00000000000..7d5929b6c0e --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/AssemblyInfo.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Microsoft.DurableTask.AzureManaged; + +[assembly: ConfigurationSchema( + "Aspire:Microsoft:DurableTask:AzureManaged", + typeof(DurableTaskSchedulerSettings))] + +[assembly: LoggingCategories( + "Microsoft.DurableTask", + "Microsoft.DurableTask.Client", + "Microsoft.DurableTask.Worker")] diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/ConfigurationSchema.json b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/ConfigurationSchema.json new file mode 100644 index 00000000000..1627e4a734a --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/ConfigurationSchema.json @@ -0,0 +1,55 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Microsoft.DurableTask": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.DurableTask.Client": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.DurableTask.Worker": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "type": "object", + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Microsoft": { + "type": "object", + "properties": { + "DurableTask": { + "type": "object", + "properties": { + "AzureManaged": { + "type": "object", + "properties": { + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string for the Durable Task Scheduler." + }, + "DisableHealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the health check is disabled or not.", + "default": false + }, + "DisableTracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.", + "default": false + } + }, + "description": "Provides the client configuration settings for connecting to a Durable Task Scheduler." + } + } + } + } + } + } + } + } +} diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerConnectionString.cs b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerConnectionString.cs new file mode 100644 index 00000000000..f8eedcb9005 --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerConnectionString.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Microsoft.DurableTask.AzureManaged; + +/// +/// Utility for parsing Durable Task Scheduler connection strings. +/// +internal static class DurableTaskSchedulerConnectionString +{ + /// + /// Extracts the Endpoint value from a Durable Task Scheduler connection string. + /// + public static string? GetEndpoint(string connectionString) + { + return TryGetValue(connectionString, "Endpoint", out var value) ? value : null; + } + + /// + /// Extracts the TaskHub value from a Durable Task Scheduler connection string. + /// + public static string? GetTaskHubName(string connectionString) + { + return TryGetValue(connectionString, "TaskHub", out var value) ? value : null; + } + + private static bool TryGetValue(string connectionString, string key, out string? value) + { + value = null; + + foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var equalsIndex = part.IndexOf('='); + if (equalsIndex < 0) + { + continue; + } + + var partKey = part.AsSpan(0, equalsIndex).Trim(); + if (partKey.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + value = part[(equalsIndex + 1)..].Trim(); + return true; + } + } + + return false; + } +} diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerHealthCheck.cs b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerHealthCheck.cs new file mode 100644 index 00000000000..03956470340 --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerHealthCheck.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Microsoft.DurableTask.AzureManaged; + +/// +/// A health check that pings the Durable Task Scheduler HTTP endpoint. +/// +internal sealed class DurableTaskSchedulerHealthCheck(string endpoint, string taskHubName) : IHealthCheck +{ + private readonly HttpClient _client = new( + new SocketsHttpHandler { ActivityHeadersPropagator = null }) + { + BaseAddress = new Uri(endpoint) + }; + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "v1/taskhubs/ping"); + request.Headers.TryAddWithoutValidation("x-taskhub", taskHubName); + + using var response = await _client.SendAsync(request, cancellationToken) + .ConfigureAwait(false); + + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy( + $"Durable Task Scheduler ping returned {response.StatusCode}."); + } + catch (Exception ex) + { + return new HealthCheckResult( + context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerSettings.cs b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerSettings.cs new file mode 100644 index 00000000000..1e92d2e6c47 --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/DurableTaskSchedulerSettings.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Microsoft.DurableTask.AzureManaged; + +/// +/// Provides the client configuration settings for connecting to a Durable Task Scheduler. +/// +/// +/// +/// Settings are read from the Aspire:Microsoft:DurableTask:AzureManaged configuration section. +/// Named instances use Aspire:Microsoft:DurableTask:AzureManaged:{name}, which takes precedence +/// over the top-level section when both are present. +/// +/// +/// +/// Configure settings via appsettings.json: +/// +/// { +/// "Aspire": { +/// "Microsoft": { +/// "DurableTask": { +/// "AzureManaged": { +/// "DisableHealthChecks": false, +/// "DisableTracing": false +/// } +/// } +/// } +/// } +/// } +/// +/// +public sealed class DurableTaskSchedulerSettings +{ + /// + /// Gets or sets the connection string for the Durable Task Scheduler. + /// + /// + /// The connection string typically has the format + /// Endpoint=http://...;Authentication=None;TaskHub=MyHub. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } +} diff --git a/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/README.md b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/README.md new file mode 100644 index 00000000000..147c375ad7c --- /dev/null +++ b/src/Components/Aspire.Microsoft.DurableTask.AzureManaged/README.md @@ -0,0 +1,143 @@ +# Aspire.Microsoft.DurableTask.AzureManaged library + +Registers a `DurableTaskClient` and a Durable Task worker in the DI container for connecting to a [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler). Enables corresponding health check, logging, and telemetry. + +## Getting started + +### Prerequisites + +- A Durable Task Scheduler instance (or the DTS emulator) and a connection string for connecting to the scheduler. + +### Install the package + +Install the Aspire Durable Task Scheduler library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Microsoft.DurableTask.AzureManaged +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddDurableTaskSchedulerWorker` extension method to register a Durable Task worker for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddDurableTaskSchedulerWorker("scheduler", worker => +{ + worker.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); +}); +``` + +By default, this also registers a `DurableTaskClient` for starting and managing orchestrations. You can retrieve it using dependency injection. For example, to retrieve the client from a Web API controller: + +```csharp +private readonly DurableTaskClient _client; + +public ProductsController(DurableTaskClient client) +{ + _client = client; +} +``` + +If you only need a client (without a worker), use the `AddDurableTaskSchedulerClient` method instead: + +```csharp +builder.AddDurableTaskSchedulerClient("scheduler"); +``` + +See the [Durable Task Scheduler documentation](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) for more information. + +## Configuration + +The Aspire Durable Task Scheduler library provides multiple options to configure the connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddDurableTaskSchedulerWorker()`: + +```csharp +builder.AddDurableTaskSchedulerWorker("scheduler", worker => { /* register tasks */ }); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "scheduler": "Endpoint=https://my-scheduler.durabletask.io;Authentication=DefaultAzure;TaskHub=MyHub" + } +} +``` + +### Use configuration providers + +The Aspire Durable Task Scheduler library supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `DurableTaskSchedulerSettings` from configuration by using the `Aspire:Microsoft:DurableTask:AzureManaged` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Microsoft": { + "DurableTask": { + "AzureManaged": { + "ConnectionString": "Endpoint=https://my-scheduler.durabletask.io;Authentication=DefaultAzure;TaskHub=MyHub", + "DisableHealthChecks": false, + "DisableTracing": false + } + } + } + } +} +``` + +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp +builder.AddDurableTaskSchedulerWorker("scheduler", worker => { /* register tasks */ }, configureSettings: settings => settings.DisableHealthChecks = true); +``` + +## AppHost extensions + +In your AppHost project, install the `Aspire.Hosting.Azure.Functions` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Azure.Functions +``` + +Then, in the _AppHost.cs_ file of `AppHost`, register a Durable Task Scheduler and consume the connection using the following methods: + +```csharp +var scheduler = builder.AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + +var taskHub = scheduler.AddTaskHub("taskhub"); + +var myService = builder.AddProject() + .WithReference(taskHub); +``` + +The `WithReference` method configures a connection in the `MyService` project named `taskhub`. In the _Program.cs_ file of `MyService`, the worker can be consumed using: + +```csharp +builder.AddDurableTaskSchedulerWorker("taskhub", worker => +{ + worker.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); +}); +``` + +## Additional documentation + +* https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler +* https://github.com/microsoft/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/microsoft/aspire diff --git a/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests.csproj b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests.csproj new file mode 100644 index 00000000000..9c89ee44e57 --- /dev/null +++ b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests.csproj @@ -0,0 +1,16 @@ + + + + $(AllTargetFrameworks) + + + + + + + + + + + + diff --git a/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/AspireDurableTaskSchedulerExtensionsTests.cs b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/AspireDurableTaskSchedulerExtensionsTests.cs new file mode 100644 index 00000000000..bd5cc1f5d3d --- /dev/null +++ b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/AspireDurableTaskSchedulerExtensionsTests.cs @@ -0,0 +1,350 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Aspire.Microsoft.DurableTask.AzureManaged.Tests; + +public class AspireDurableTaskSchedulerExtensionsTests +{ + private const string TestConnectionString = "Endpoint=http://localhost:8080;Authentication=None;TaskHub=TestHub"; + + [Fact] + public void AddDurableTaskSchedulerWorker_ThrowsWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + Assert.Throws(() => + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { })); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ThrowsWhenConnectionNameIsNullOrEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.ThrowsAny(() => + builder.AddDurableTaskSchedulerWorker(null!, _ => { })); + + Assert.ThrowsAny(() => + builder.AddDurableTaskSchedulerWorker("", _ => { })); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ThrowsWhenConfigureWorkerIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.Throws(() => + builder.AddDurableTaskSchedulerWorker("scheduler", null!)); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ThrowsWhenConnectionStringIsMissing() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.Throws(() => + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { })); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ReadsConnectionStringFromConfiguration() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + + // Worker host service should be registered + var hostedServices = host.Services.GetServices(); + Assert.NotEmpty(hostedServices); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ReadsConnectionStringFromConfigSection() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Microsoft:DurableTask:AzureManaged:ConnectionString", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + var hostedServices = host.Services.GetServices(); + Assert.NotEmpty(hostedServices); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_RegistersClientByDefault() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + var client = host.Services.GetService(); + Assert.NotNull(client); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_DoesNotRegisterClientWhenOptedOut() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }, includeClient: false); + + using var host = builder.Build(); + var client = host.Services.GetService(); + Assert.Null(client); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_RegistersHealthCheckByDefault() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + var healthCheckService = host.Services.GetService(); + Assert.NotNull(healthCheckService); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_HealthCheckCanBeDisabled() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString), + new KeyValuePair("Aspire:Microsoft:DurableTask:AzureManaged:DisableHealthChecks", "true") + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + + // Health check registrations should be available (infrastructure), but no DTS-specific one + var options = host.Services.GetService>(); + Assert.NotNull(options); + + var registrations = options.Value.Registrations; + Assert.DoesNotContain(registrations, r => r.Name.StartsWith("DurableTaskScheduler_", StringComparison.Ordinal)); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_HealthCheckCanBeDisabledViaDelegate() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }, configureSettings: s => s.DisableHealthChecks = true); + + using var host = builder.Build(); + var options = host.Services.GetService>(); + Assert.NotNull(options); + + var registrations = options.Value.Registrations; + Assert.DoesNotContain(registrations, r => r.Name.StartsWith("DurableTaskScheduler_", StringComparison.Ordinal)); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ConfigureSettingsDelegateIsInvoked() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + var invoked = false; + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }, configureSettings: _ => invoked = true); + + Assert.True(invoked); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_NamedConfigOverridesTopLevelConfig() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Microsoft:DurableTask:AzureManaged:DisableHealthChecks", "false"), + new KeyValuePair("Aspire:Microsoft:DurableTask:AzureManaged:scheduler:DisableHealthChecks", "true"), + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + var options = host.Services.GetService>(); + Assert.NotNull(options); + + var registrations = options.Value.Registrations; + Assert.DoesNotContain(registrations, r => r.Name.StartsWith("DurableTaskScheduler_", StringComparison.Ordinal)); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ThrowsWhenEndpointMissingFromConnectionString() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", "Authentication=None;TaskHub=TestHub") + ]); + + Assert.ThrowsAny(() => + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { })); + } + + [Fact] + public void AddDurableTaskSchedulerWorker_ThrowsWhenTaskHubMissingFromConnectionString() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", "Endpoint=http://localhost:8080;Authentication=None") + ]); + + Assert.ThrowsAny(() => + builder.AddDurableTaskSchedulerWorker("scheduler", _ => { })); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerWorker_ThrowsWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + Assert.Throws(() => + builder.AddKeyedDurableTaskSchedulerWorker("scheduler", _ => { })); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerWorker_ThrowsWhenNameIsNullOrEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.ThrowsAny(() => + builder.AddKeyedDurableTaskSchedulerWorker(null!, _ => { })); + + Assert.ThrowsAny(() => + builder.AddKeyedDurableTaskSchedulerWorker("", _ => { })); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerWorker_ThrowsWhenConfigureWorkerIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.Throws(() => + builder.AddKeyedDurableTaskSchedulerWorker("scheduler", null!)); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerWorker_ReadsConnectionStringFromConfiguration() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddKeyedDurableTaskSchedulerWorker("scheduler", _ => { }); + + using var host = builder.Build(); + var hostedServices = host.Services.GetServices(); + Assert.NotEmpty(hostedServices); + } + + [Fact] + public void AddDurableTaskSchedulerClient_ThrowsWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + Assert.Throws(() => + builder.AddDurableTaskSchedulerClient("scheduler")); + } + + [Fact] + public void AddDurableTaskSchedulerClient_ThrowsWhenConnectionNameIsNullOrEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.ThrowsAny(() => + builder.AddDurableTaskSchedulerClient(null!)); + + Assert.ThrowsAny(() => + builder.AddDurableTaskSchedulerClient("")); + } + + [Fact] + public void AddDurableTaskSchedulerClient_RegistersClient() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddDurableTaskSchedulerClient("scheduler"); + + using var host = builder.Build(); + var client = host.Services.GetService(); + Assert.NotNull(client); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerClient_ThrowsWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + Assert.Throws(() => + builder.AddKeyedDurableTaskSchedulerClient("scheduler")); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerClient_ThrowsWhenNameIsNullOrEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + Assert.ThrowsAny(() => + builder.AddKeyedDurableTaskSchedulerClient(null!)); + + Assert.ThrowsAny(() => + builder.AddKeyedDurableTaskSchedulerClient("")); + } + + [Fact] + public void AddKeyedDurableTaskSchedulerClient_RegistersServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:scheduler", TestConnectionString) + ]); + + builder.AddKeyedDurableTaskSchedulerClient("scheduler"); + + using var host = builder.Build(); + + // Verify services were registered (keyed DurableTaskClient may not resolve + // directly via GetKeyedService since the DT SDK uses its own factory pattern) + var hostedServices = host.Services.GetServices(); + Assert.NotEmpty(hostedServices); + } +} diff --git a/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/ConfigurationSchemaTests.cs b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/ConfigurationSchemaTests.cs new file mode 100644 index 00000000000..c02836ae2e6 --- /dev/null +++ b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/ConfigurationSchemaTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Json.Schema; +using Xunit; + +namespace Aspire.Microsoft.DurableTask.AzureManaged.Tests; + +public class ConfigurationSchemaTests +{ + private static readonly string s_schemaPath = Path.Combine( + AppContext.BaseDirectory, "ConfigurationSchema.json"); + + [Fact] + public void SchemaFileExists() + { + Assert.True(File.Exists(s_schemaPath), $"Schema file not found at {s_schemaPath}"); + } + + [Fact] + public void ValidJsonConfigPassesValidation() + { + var schema = JsonSchema.FromFile(s_schemaPath); + + var validConfig = JsonNode.Parse(""" + { + "Aspire": { + "Microsoft": { + "DurableTask": { + "AzureManaged": { + "ConnectionString": "Endpoint=http://localhost:8080;Authentication=None;TaskHub=MyHub", + "DisableHealthChecks": false, + "DisableTracing": false + } + } + } + } + } + """); + + var results = schema.Evaluate(validConfig); + Assert.True(results.IsValid); + } + + [Theory] + [InlineData("""{"Aspire": { "Microsoft": { "DurableTask": { "AzureManaged": { "DisableHealthChecks": "notabool"}}}}}""", "Value is \"string\" but should be \"boolean\"")] + [InlineData("""{"Aspire": { "Microsoft": { "DurableTask": { "AzureManaged": { "DisableTracing": "notabool"}}}}}""", "Value is \"string\" but should be \"boolean\"")] + public void InvalidJsonConfigFailsValidation(string json, string expectedError) + { + var schema = JsonSchema.FromFile(s_schemaPath); + + var config = JsonNode.Parse(json); + var results = schema.Evaluate(config, new EvaluationOptions { OutputFormat = OutputFormat.List }); + var detail = results.Details.FirstOrDefault(x => x.HasErrors); + + Assert.NotNull(detail); + Assert.Equal(expectedError, detail.Errors!.First().Value); + } +} diff --git a/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs new file mode 100644 index 00000000000..c2828b9e8c4 --- /dev/null +++ b/tests/Aspire.Microsoft.DurableTask.AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Microsoft.DurableTask.AzureManaged.Tests; + +public class DurableTaskSchedulerConnectionStringTests +{ + [Theory] + [InlineData("Endpoint=http://localhost:8080;Authentication=None;TaskHub=MyHub", "http://localhost:8080")] + [InlineData("Endpoint=https://my-scheduler.durabletask.io;Authentication=DefaultAzure;TaskHub=Hub1", "https://my-scheduler.durabletask.io")] + [InlineData("endpoint=http://localhost:8080;Authentication=None;TaskHub=MyHub", "http://localhost:8080")] + [InlineData(" Endpoint = http://localhost:8080 ;Authentication=None;TaskHub=MyHub", "http://localhost:8080")] + public void GetEndpoint_ReturnsExpectedValue(string connectionString, string expectedEndpoint) + { + var result = DurableTaskSchedulerConnectionString.GetEndpoint(connectionString); + Assert.Equal(expectedEndpoint, result); + } + + [Theory] + [InlineData("Endpoint=http://localhost:8080;Authentication=None;TaskHub=MyHub", "MyHub")] + [InlineData("Endpoint=https://my-scheduler.durabletask.io;Authentication=DefaultAzure;TaskHub=Hub1", "Hub1")] + [InlineData("Endpoint=http://localhost:8080;Authentication=None;taskhub=MyHub", "MyHub")] + [InlineData("Endpoint=http://localhost:8080;Authentication=None; TaskHub = MyHub ", "MyHub")] + public void GetTaskHubName_ReturnsExpectedValue(string connectionString, string expectedTaskHub) + { + var result = DurableTaskSchedulerConnectionString.GetTaskHubName(connectionString); + Assert.Equal(expectedTaskHub, result); + } + + [Theory] + [InlineData("Authentication=None;TaskHub=MyHub")] + [InlineData("")] + [InlineData("TaskHub=MyHub")] + public void GetEndpoint_ReturnsNull_WhenEndpointIsMissing(string connectionString) + { + var result = DurableTaskSchedulerConnectionString.GetEndpoint(connectionString); + Assert.Null(result); + } + + [Theory] + [InlineData("Endpoint=http://localhost:8080;Authentication=None")] + [InlineData("")] + [InlineData("Endpoint=http://localhost:8080")] + public void GetTaskHubName_ReturnsNull_WhenTaskHubIsMissing(string connectionString) + { + var result = DurableTaskSchedulerConnectionString.GetTaskHubName(connectionString); + Assert.Null(result); + } + + [Fact] + public void GetEndpoint_HandlesMalformedSegments() + { + // Segment without '=' should be skipped gracefully + var result = DurableTaskSchedulerConnectionString.GetEndpoint("NoEqualsHere;Endpoint=http://localhost:8080;TaskHub=Hub"); + Assert.Equal("http://localhost:8080", result); + } +}