diff --git a/Aspire.slnx b/Aspire.slnx index 52300ace4ae..4ea6dbc4cdc 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -142,6 +142,10 @@ + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 7be83c1fba4..6f47a76767e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -174,6 +174,9 @@ + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj new file mode 100644 index 00000000000..141e339b6f1 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + DC3A64A6-3991-41E2-956F-BFACC8091EC1 + + + + + + + + + + + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs new file mode 100644 index 00000000000..ff0e39fb7be --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs @@ -0,0 +1,13 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + +var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + +var taskHub = scheduler.AddTaskHub("taskhub"); + +builder.AddAzureFunctionsProject("funcapp") + .WithHostStorage(storage) + .WithReference(taskHub); + +builder.Build().Run(); diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Properties/launchSettings.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..f5f441697c0 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.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:17244;http://localhost:15054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21003", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22110" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19010", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20125" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175" + } + } + } +} diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj new file mode 100644 index 00000000000..ca8d05550d1 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj @@ -0,0 +1,43 @@ + + + $(DefaultTargetFramework) + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + + + + + + + + + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs new file mode 100644 index 00000000000..18a9be7d909 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs @@ -0,0 +1,29 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +public class MyOrchestrationTrigger +{ + [Function("Chaining")] + public static async Task Run( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + ILogger logger = context.CreateReplaySafeLogger(nameof(MyOrchestrationTrigger)); + logger.LogInformation("Saying hello."); + var outputs = new List(); + + // Replace name and input with values relevant for your Durable Functions Activity + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Tokyo")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Seattle")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "London")); + + // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + return outputs; + } + + [Function(nameof(SayHello))] + public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext) + { + return $"Hello {name}!"; + } +} diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs new file mode 100644 index 00000000000..2ede5b1469f --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.ConfigureFunctionsWebApplication(); + +var host = builder.Build(); + +host.Run(); diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json new file mode 100644 index 00000000000..fa595ad7768 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "AzureFunctionsWithDts_Functions": { + "commandName": "Project", + "commandLineArgs": "--port 7071", + "launchBrowser": false + } + } +} diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json new file mode 100644 index 00000000000..455e0e8d677 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + }, + "telemetryMode": "openTelemetry" +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs new file mode 100644 index 00000000000..a5f760aacdd --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Annotation that supplies the name for an existing Durable Task hub resource. +/// +/// The name of the existing Durable Task hub. +internal sealed class DurableTaskHubNameAnnotation(object hubName) : IResourceAnnotation +{ + /// + /// Gets the name of the existing Durable Task hub. + /// + public object HubName { get; } = hubName; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs new file mode 100644 index 00000000000..92440c6707e --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Represents a Durable Task hub resource. A Task Hub groups durable orchestrations and activities. +/// This resource extends the scheduler connection string with the TaskHub name so that clients can +/// connect to the correct hub. +/// +/// The logical name of the Task Hub (used as the TaskHub value). +/// The durable task scheduler resource whose connection string is the base for this hub. +[AspireExport(ExposeProperties = true)] +public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler) + : Resource(name), IResourceWithConnectionString, IResourceWithParent, IResourceWithAzureFunctionsConfig +{ + /// + /// Gets the connection string expression composed of the scheduler connection string and the TaskHub name. + /// + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{Parent.ConnectionStringExpression};TaskHub={TaskHubName}"); + + /// + /// Gets the parent durable task scheduler resource that provides the base connection string. + /// + public DurableTaskSchedulerResource Parent => scheduler; + + /// + /// Gets the name of the Task Hub. If not provided, the logical name of this resource is returned. + /// + public ReferenceExpression TaskHubName => GetTaskHubName(); + + /// + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + { + // Injected to support Azure Functions listener initialization via the DTS storage provider. + target["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = Parent.ConnectionStringExpression; + target["TASKHUB_NAME"] = TaskHubName; + } + + private ReferenceExpression GetTaskHubName() + { + if (this.TryGetLastAnnotation(out var taskHubNameAnnotation)) + { + return taskHubNameAnnotation.HubName switch + { + ParameterResource parameter => ReferenceExpression.Create($"{parameter}"), + string hubName => ReferenceExpression.Create($"{hubName}"), + _ => throw new InvalidOperationException($"Unexpected Task Hub name type: {taskHubNameAnnotation.HubName.GetType().Name}") + }; + } + + return ReferenceExpression.Create($"{Name}"); + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs new file mode 100644 index 00000000000..b16df76e6a0 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -0,0 +1,284 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.DurableTask; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding and configuring Durable Task resources within a distributed application. +/// +public static class DurableTaskResourceExtensions +{ + /// + /// Adds a Durable Task scheduler resource to the distributed application. + /// + /// The distributed application builder. + /// The logical name of the scheduler resource. + /// An for the scheduler resource. + /// + /// Add a Durable Task scheduler resource: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler"); + /// + /// + [AspireExport("addDurableTaskScheduler", Description = "Adds a Durable Task scheduler resource to the distributed application.")] + public static IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, [ResourceName] string name) + { + var scheduler = new DurableTaskSchedulerResource(name); + + scheduler.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); + + return builder.AddResource(scheduler); + } + + /// + /// Configures the Durable Task scheduler to use an existing scheduler instance referenced by the provided connection string. + /// No new scheduler resource is provisioned. + /// + /// The scheduler resource builder. + /// The connection string referencing the existing Durable Task scheduler instance. + /// The same instance for fluent chaining. + /// + /// The existing resource annotation is only applied when the execution context is not in publish mode. + /// + /// + /// Use an existing scheduler instead of provisioning a new one: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting("Endpoint=https://example;...;"); + /// + /// + [AspireExportIgnore(Reason = "Polyglot export is via RunAsExistingCore which accepts both string and parameter resource inputs.")] + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, string connectionString) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new DurableTaskSchedulerConnectionStringAnnotation(connectionString)); + } + + return builder; + } + + /// + /// Configures the Durable Task scheduler to use an existing scheduler instance referenced by the provided connection string. + /// No new scheduler resource is provisioned. + /// + /// The scheduler resource builder. + /// The connection string parameter referencing the existing Durable Task scheduler instance. + /// The same instance for fluent chaining. + /// + /// The existing resource annotation is only applied when the execution context is not in publish mode. + /// + /// + /// Use an existing scheduler where the connection string is supplied via a parameter: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var schedulerConnectionString = builder.AddParameter("schedulerConnectionString"); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting(schedulerConnectionString); + /// + /// + [AspireExportIgnore(Reason = "Polyglot export is via RunAsExistingCore which accepts both string and parameter resource inputs.")] + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, IResourceBuilder connectionString) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new DurableTaskSchedulerConnectionStringAnnotation(connectionString.Resource)); + } + + return builder; + } + + [AspireExport("runAsExisting", Description = "Configures the Durable Task scheduler to use an existing scheduler instance from a connection string or parameter resource.")] + internal static IResourceBuilder RunAsExistingCore( + this IResourceBuilder builder, + [AspireUnion(typeof(string), typeof(IResourceBuilder))] object connectionString) + => connectionString switch + { + string value => builder.RunAsExisting(value), + IResourceBuilder parameter => builder.RunAsExisting(parameter), + _ => throw new ArgumentException($"Unexpected connection string type: {connectionString.GetType().Name}", nameof(connectionString)) + }; + + /// + /// Configures the Durable Task scheduler to run using the local emulator (only in non-publish modes). + /// + /// The resource builder for the scheduler. + /// Callback that exposes underlying container used for emulation to allow for customization. + /// The same instance for chaining. + /// + /// Run the scheduler locally using the emulator: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(); + /// + /// + [AspireExport("runAsEmulator", Description = "Configures the Durable Task scheduler to run using the local emulator.", RunSyncOnBackgroundThread = true)] + public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + // Mark this resource as an emulator for consistent resource identification and tooling support + builder.WithAnnotation(new EmulatorResourceAnnotation()); + + builder.WithHttpEndpoint(name: "grpc", targetPort: 8080) + .WithEndpoint("grpc", endpoint => endpoint.Transport = "http2") + .WithHttpEndpoint(name: "http", targetPort: 8081) + .WithHttpEndpoint(name: "dashboard", targetPort: 8082) + .WithUrlForEndpoint("dashboard", c => c.DisplayText = "Scheduler Dashboard") + .WithAnnotation(new ContainerImageAnnotation + { + Registry = DurableTaskSchedulerEmulatorContainerImageTags.Registry, + Image = DurableTaskSchedulerEmulatorContainerImageTags.Image, + Tag = DurableTaskSchedulerEmulatorContainerImageTags.Tag + }); + + var emulatorResource = new DurableTaskSchedulerEmulatorResource(builder.Resource); + + var surrogateBuilder = + builder + .ApplicationBuilder + .CreateResourceBuilder(emulatorResource) + .WithEnvironment( + context => + { + ReferenceExpressionBuilder namesBuilder = new(); + + var durableTaskHubNames = + builder + .ApplicationBuilder + .Resources + .OfType() + .Where(th => th.Parent == builder.Resource) + .Select(th => th.TaskHubName) + .ToList(); + + for (int i = 0; i < durableTaskHubNames.Count; i++) + { + if (i > 0) + { + namesBuilder.AppendLiteral(", "); + } + + namesBuilder.AppendFormatted(durableTaskHubNames[i]); + } + + context.EnvironmentVariables["DTS_TASK_HUB_NAMES"] = namesBuilder.Build(); + }); + + configureContainer?.Invoke(surrogateBuilder); + + return builder; + } + + /// + /// Adds a Durable Task hub resource associated with the specified scheduler. + /// + /// The scheduler resource builder. + /// The logical name of the task hub resource. + /// An for the task hub resource. + /// + /// Add a task hub under a scheduler: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + /// + /// var hub = scheduler.AddTaskHub("hub") + /// .WithTaskHubName("MyTaskHub"); + /// + /// + [AspireExport("addTaskHub", Description = "Adds a Durable Task hub resource associated with the scheduler.")] + public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, [ResourceName] string name) + { + var hub = new DurableTaskHubResource(name, builder.Resource); + + hub.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); + + var hubBuilder = builder.ApplicationBuilder.AddResource(hub); + + hubBuilder.OnResourceReady( + async (r, e, ct) => + { + var notifications = e.Services.GetRequiredService(); + + var url = builder.Resource.IsEmulator + ? await ReferenceExpression.Create($"{r.Parent.EmulatorDashboardEndpoint}/subscriptions/default/schedulers/default/taskhubs/{r.TaskHubName}").GetValueAsync(ct).ConfigureAwait(false) + : null; + + await notifications.PublishUpdateAsync(r, snapshot => snapshot with + { + State = KnownResourceStates.Running, + Urls = url is not null + ? [new("dashboard", url, false) { DisplayProperties = new() { DisplayName = "Task Hub Dashboard" } }] + : [] + }).ConfigureAwait(false); + }); + + return hubBuilder; + } + + /// + /// Sets the name of the Durable Task hub. + /// + /// The task hub resource builder. + /// The name of the Task Hub. + /// The same instance for fluent chaining. + /// + /// Set the task hub name: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + /// var hub = scheduler.AddTaskHub("hub").WithTaskHubName("MyTaskHub"); + /// + /// + [AspireExportIgnore(Reason = "Polyglot export is via WithTaskHubNameCore which accepts both string and parameter resource inputs.")] + public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, string taskHubName) + { + return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName)); + } + + /// + /// Sets the name of the Durable Task hub using a parameter resource. + /// + /// The task hub resource builder. + /// A parameter resource that resolves to the Task Hub name. + /// The same instance for fluent chaining. + /// + /// Set the task hub name from a parameter: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var taskHubName = builder.AddParameter("taskHubName"); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + /// var hub = scheduler.AddTaskHub("hub").WithTaskHubName(taskHubName); + /// + /// + [AspireExportIgnore(Reason = "Polyglot export is via WithTaskHubNameCore which accepts both string and parameter resource inputs.")] + public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, IResourceBuilder taskHubName) + { + return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName.Resource)); + } + + [AspireExport("withTaskHubName", Description = "Sets the Durable Task hub name from a string or parameter resource.")] + internal static IResourceBuilder WithTaskHubNameCore( + this IResourceBuilder builder, + [AspireUnion(typeof(string), typeof(IResourceBuilder))] object taskHubName) + => taskHubName switch + { + string value => builder.WithTaskHubName(value), + IResourceBuilder parameter => builder.WithTaskHubName(parameter), + _ => throw new ArgumentException($"Unexpected task hub name type: {taskHubName.GetType().Name}", nameof(taskHubName)) + }; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs new file mode 100644 index 00000000000..c14ac070c45 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Annotation that supplies the connection string for an existing Durable Task scheduler resource. +/// +/// The connection string of the existing Durable Task scheduler. +internal sealed class DurableTaskSchedulerConnectionStringAnnotation(object connectionString) : IResourceAnnotation +{ + /// + /// Gets the connection string of the existing Durable Task scheduler. + /// + public object ConnectionString { get; } = connectionString; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs new file mode 100644 index 00000000000..015f8bd2784 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Azure.DurableTask; + +internal static class DurableTaskSchedulerEmulatorContainerImageTags +{ + /// mcr.microsoft.com + public const string Registry = "mcr.microsoft.com"; + + /// dts/dts-emulator + public const string Image = "dts/dts-emulator"; + + /// latest + public const string Tag = "latest"; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs new file mode 100644 index 00000000000..da207a102d9 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Represents the containerized emulator resource for a . +/// This is used to host the Durable Task scheduler logic when running locally (e.g. with an Azure Functions emulator). +/// +/// The underlying durable task scheduler resource that provides naming and annotations. +/// +/// The emulator resource delegates its annotation collection to the underlying scheduler so that configuration +/// and metadata remain consistent across both representations. +/// +public sealed class DurableTaskSchedulerEmulatorResource(DurableTaskSchedulerResource scheduler) : ContainerResource(scheduler.Name) +{ + /// + public override ResourceAnnotationCollection Annotations => scheduler.Annotations; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs new file mode 100644 index 00000000000..ab099d63795 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Represents a Durable Task scheduler resource used in Aspire hosting that provides endpoints +/// and a connection string for Durable Task orchestration scheduling. +/// +/// The unique resource name. +public sealed class DurableTaskSchedulerResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithConnectionString +{ + /// + /// Gets the expression that resolves to the connection string for the Durable Task scheduler. + /// + public ReferenceExpression ConnectionStringExpression => CreateConnectionString(); + + internal ReferenceExpression EmulatorDashboardEndpoint => CreateDashboardEndpoint(); + + /// + /// Gets a value indicating whether the Durable Task scheduler is running using the local + /// emulator (container) instead of a cloud-hosted service. + /// + public bool IsEmulator => this.IsContainer(); + + private ReferenceExpression CreateConnectionString() + { + if (IsEmulator) + { + var grpcEndpoint = new EndpointReference(this, "grpc"); + + return ReferenceExpression.Create($"Endpoint={grpcEndpoint.Property(EndpointProperty.Scheme)}://{grpcEndpoint.Property(EndpointProperty.Host)}:{grpcEndpoint.Property(EndpointProperty.Port)};Authentication=None"); + } + + if (this.TryGetLastAnnotation(out var connectionStringAnnotation)) + { + return connectionStringAnnotation.ConnectionString switch + { + ParameterResource parameterResource => ReferenceExpression.Create($"{parameterResource}"), + string value => ReferenceExpression.Create($"{value}"), + _ => throw new InvalidOperationException($"Unexpected connection string type: {connectionStringAnnotation.ConnectionString.GetType().Name}"), + }; + } + + throw new InvalidOperationException($"Unable to resolve the Durable Task Scheduler connection string. Configure the scheduler using {nameof(DurableTaskResourceExtensions.RunAsEmulator)}() or {nameof(DurableTaskResourceExtensions.RunAsExisting)}(connectionString) before accessing {nameof(ConnectionStringExpression)}."); + } + + private ReferenceExpression CreateDashboardEndpoint() + { + if (IsEmulator) + { + var dashboardEndpoint = new EndpointReference(this, "dashboard"); + + return ReferenceExpression.Create($"{dashboardEndpoint.Property(EndpointProperty.Url)}"); + } + + throw new InvalidOperationException("Dashboard endpoint is only available when running as an emulator."); + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/README.md b/src/Aspire.Hosting.Azure.Functions/README.md index 357ff30a739..86ef7ff7cec 100644 --- a/src/Aspire.Hosting.Azure.Functions/README.md +++ b/src/Aspire.Hosting.Azure.Functions/README.md @@ -47,6 +47,63 @@ var app = builder.Build(); app.Run(); ``` +## Durable Task Scheduler (Durable Functions) + +The Azure Functions hosting library also provides resource APIs for using the Durable Task Scheduler (DTS) with Durable Functions. + +In the _AppHost.cs_ file of `AppHost`, add a Scheduler resource, create one or more Task Hubs, and pass the connection string and hub name to your Functions project: + +```csharp +using Aspire.Hosting; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Azure.Functions; + +var builder = DistributedApplication.CreateBuilder(args); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + +var scheduler = builder.AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + +var taskHub = scheduler.AddTaskHub("taskhub"); + +builder.AddAzureFunctionsProject("funcapp") + .WithHostStorage(storage) + .WithReference(taskHub); + +builder.Build().Run(); +``` + +### Use the DTS emulator + +`RunAsEmulator()` starts a local container running the Durable Task Scheduler emulator. + +When a Scheduler runs as an emulator, Aspire automatically provides: + +- A "Scheduler Dashboard" URL for the scheduler resource. +- 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. + +### Use an existing Scheduler + +If you already have a Scheduler instance, configure the resource using its connection string: + +```csharp +var schedulerConnectionString = builder.AddParameter( + "dts-connection-string", + "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"); + +var scheduler = builder.AddDurableTaskScheduler("scheduler") + .RunAsExisting(schedulerConnectionString); + +var taskHubName = builder.AddParameter("taskhub-name", "mytaskhub"); +var taskHub = scheduler.AddTaskHub("taskhub").WithTaskHubName(taskHubName); +``` +## Additional documentation + +- https://learn.microsoft.com/azure/azure-functions +- https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler + ## Feedback & contributing https://github.com/microsoft/aspire diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs new file mode 100644 index 00000000000..71e23e99523 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -0,0 +1,256 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class DurableTaskResourceExtensionsTests +{ + [Fact] + public async Task AddDurableTaskScheduler_RunAsEmulator_ResolvedConnectionString() + { + string expectedConnectionString = "Endpoint=http://localhost:8080;Authentication=None"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsEmulator(e => + { + e.WithEndpoint("grpc", e => e.AllocatedEndpoint = new(e, "localhost", 8080)); + e.WithEndpoint("http", e => e.AllocatedEndpoint = new(e, "localhost", 8081)); + e.WithEndpoint("dashboard", e => e.AllocatedEndpoint = new(e, "localhost", 8082)); + }); + + var connectionString = await dts.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Fact] + public async Task AddDurableTaskScheduler_RunAsExisting_ResolvedConnectionString() + { + string expectedConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsExisting(expectedConnectionString); + + var connectionString = await dts.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Fact] + public async Task AddDurableTaskScheduler_RunAsExisting_ResolvedConnectionStringParameter() + { + string expectedConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var connectionStringParameter = builder.AddParameter("dts-connection-string", expectedConnectionString); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsExisting(connectionStringParameter); + + var connectionString = await dts.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Theory] + [InlineData(null, "mytaskhub")] + [InlineData("myrealtaskhub", "myrealtaskhub")] + public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParameter(string? taskHubName, string expectedTaskHubName) + { + string dtsConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + string expectedConnectionString = $"{dtsConnectionString};TaskHub={expectedTaskHubName}"; + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsExisting(dtsConnectionString); + + var taskHub = dts.AddTaskHub("mytaskhub"); + + if (taskHubName is not null) + { + taskHub = taskHub.WithTaskHubName(taskHubName); + } + + var connectionString = await taskHub.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Fact] + public void AddDurableTaskScheduler_IsExcludedFromPublishingManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts"); + + Assert.True(dts.Resource.TryGetAnnotationsOfType(out var manifestAnnotations)); + var annotation = Assert.Single(manifestAnnotations); + Assert.Equal(ManifestPublishingCallbackAnnotation.Ignore, annotation); + } + + [Fact] + public void AddDurableTaskHub_IsExcludedFromPublishingManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts").RunAsExisting("Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"); + var taskHub = dts.AddTaskHub("hub"); + + Assert.True(taskHub.Resource.TryGetAnnotationsOfType(out var manifestAnnotations)); + var annotation = Assert.Single(manifestAnnotations); + Assert.Equal(ManifestPublishingCallbackAnnotation.Ignore, annotation); + } + + [Fact] + public void RunAsExisting_InPublishMode_DoesNotApplyConnectionStringAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsExisting("Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"); + + Assert.False(dts.ApplicationBuilder.ExecutionContext.IsRunMode); + Assert.True(dts.ApplicationBuilder.ExecutionContext.IsPublishMode); + + var ex = Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); + Assert.Contains("Unable to resolve the Durable Task Scheduler connection string", ex.Message); + } + + [Fact] + public void RunAsEmulator_InPublishMode_IsNoOp() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsEmulator(); + + Assert.False(dts.Resource.IsEmulator); + Assert.DoesNotContain(dts.Resource.Annotations, a => a is EmulatorResourceAnnotation); + + Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); + } + + [Fact] + public void RunAsEmulator_AddsEmulatorAnnotationContainerImageAndEndpoints() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsEmulator(); + + Assert.True(dts.Resource.IsEmulator); + + var emulatorAnnotation = dts.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(emulatorAnnotation); + + var containerImageAnnotation = dts.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(containerImageAnnotation); + Assert.Equal("mcr.microsoft.com", containerImageAnnotation.Registry); + Assert.Equal("dts/dts-emulator", containerImageAnnotation.Image); + Assert.Equal("latest", containerImageAnnotation.Tag); + + var endpointAnnotations = dts.Resource.Annotations.OfType().ToList(); + + var grpc = endpointAnnotations.SingleOrDefault(e => e.Name == "grpc"); + Assert.NotNull(grpc); + Assert.Equal(8080, grpc.TargetPort); + + var http = endpointAnnotations.SingleOrDefault(e => e.Name == "http"); + Assert.NotNull(http); + Assert.Equal(8081, http.TargetPort); + Assert.Equal("http", http.UriScheme); + + var dashboard = endpointAnnotations.SingleOrDefault(e => e.Name == "dashboard"); + Assert.NotNull(dashboard); + Assert.Equal(8082, dashboard.TargetPort); + Assert.Equal("http", dashboard.UriScheme); + } + + [Fact] + public async Task RunAsEmulator_SetsSingleDtsTaskHubNamesEnvironmentVariable() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts").RunAsEmulator(); + + _ = dts.AddTaskHub("hub1").WithTaskHubName("realhub1"); + + var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("realhub1", env["DTS_TASK_HUB_NAMES"]); + } + + [Fact] + public async Task RunAsEmulator_SetsMultipleDtsTaskHubNamesEnvironmentVariable() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts").RunAsEmulator(); + + _ = dts.AddTaskHub("hub1"); + _ = dts.AddTaskHub("hub2").WithTaskHubName("realhub2"); + + var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("hub1, realhub2", env["DTS_TASK_HUB_NAMES"]); + } + + [Fact] + public async Task RunAsEmulator_DtsTaskHubNamesOnlyIncludesHubsForSameScheduler() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts1 = builder.AddDurableTaskScheduler("dts1").RunAsEmulator(); + var dts2 = builder.AddDurableTaskScheduler("dts2").RunAsEmulator(); + + _ = dts1.AddTaskHub("hub1"); + _ = dts2.AddTaskHub("hub2"); + + var env1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts1.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + var env2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts2.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("hub1", env1["DTS_TASK_HUB_NAMES"]); + Assert.Equal("hub2", env2["DTS_TASK_HUB_NAMES"]); + } + + [Fact] + public async Task WithTaskHubName_Parameter_ResolvedConnectionString() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + const string dtsConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + var hubNameParameter = builder.AddParameter("hub-name", "parameterHub"); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsExisting(dtsConnectionString); + + var hub = dts.AddTaskHub("ignored").WithTaskHubName(hubNameParameter); + + var connectionString = await hub.Resource.ConnectionStringExpression.GetValueAsync(default); + Assert.Equal($"{dtsConnectionString};TaskHub=parameterHub", connectionString); + } + + [Fact] + public void DurableTaskSchedulerResource_WithoutEmulatorOrExistingConnectionString_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts"); + + var ex = Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); + Assert.Contains("Unable to resolve the Durable Task Scheduler connection string", ex.Message); + } +}