diff --git a/Aspire.slnx b/Aspire.slnx index 243909d5080..3d5e9ae5f0e 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -153,6 +153,12 @@ + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 935bda3f634..6eb496bfdc8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -88,6 +88,8 @@ + + diff --git a/playground/DurableTask/DurableTask.Scheduler.AppHost/DurableTask.Scheduler.AppHost.csproj b/playground/DurableTask/DurableTask.Scheduler.AppHost/DurableTask.Scheduler.AppHost.csproj new file mode 100644 index 00000000000..cc1a737c1e6 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.AppHost/DurableTask.Scheduler.AppHost.csproj @@ -0,0 +1,25 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/playground/DurableTask/DurableTask.Scheduler.AppHost/Program.cs b/playground/DurableTask/DurableTask.Scheduler.AppHost/Program.cs new file mode 100644 index 00000000000..494e4355b41 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.AppHost/Program.cs @@ -0,0 +1,23 @@ +using Aspire.Hosting.Azure; + +var builder = DistributedApplication.CreateBuilder(args); + +var scheduler = + builder.AddDurableTaskScheduler("scheduler") + .RunAsEmulator( + options => + { + options.WithDynamicTaskHubs(); + }); + +var taskHub = scheduler.AddTaskHub("taskhub"); + +var webApi = + builder.AddProject("webapi") + .WithReference(taskHub); + +builder.AddProject("worker") + .WithReference(webApi) + .WithReference(taskHub); + +builder.Build().Run(); diff --git a/playground/DurableTask/DurableTask.Scheduler.AppHost/Properties/launchSettings.json b/playground/DurableTask/DurableTask.Scheduler.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..ec446d195a0 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17222;http://localhost:15079", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21093", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22284" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15079", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19250", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20227" + } + } + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.AppHost/appsettings.json b/playground/DurableTask/DurableTask.Scheduler.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/DurableTask.Scheduler.ExternalAppHost.csproj b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/DurableTask.Scheduler.ExternalAppHost.csproj new file mode 100644 index 00000000000..cc1a737c1e6 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/DurableTask.Scheduler.ExternalAppHost.csproj @@ -0,0 +1,25 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/Program.cs b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/Program.cs new file mode 100644 index 00000000000..679b8316e8b --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/Program.cs @@ -0,0 +1,21 @@ +using Aspire.Hosting.Azure; + +var builder = DistributedApplication.CreateBuilder(args); + +var scheduler = + builder.AddDurableTaskScheduler("scheduler") + .RunAsExisting(builder.AddParameter("scheduler-connection-string")); + +var taskHub = + scheduler.AddTaskHub("taskhub") + .WithTaskHubName(builder.AddParameter("taskhub-name")); + +var webApi = + builder.AddProject("webapi") + .WithReference(taskHub); + +builder.AddProject("worker") + .WithReference(webApi) + .WithReference(taskHub); + +builder.Build().Run(); diff --git a/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/Properties/launchSettings.json b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..ec446d195a0 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17222;http://localhost:15079", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21093", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22284" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15079", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19250", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20227" + } + } + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/appsettings.json b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/appsettings.json new file mode 100644 index 00000000000..4a65f26cfac --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.ExternalAppHost/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "Parameters": { + "scheduler-connection-string": "", + "taskhub-name": "" + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.WebApi/DurableTask.Scheduler.WebApi.csproj b/playground/DurableTask/DurableTask.Scheduler.WebApi/DurableTask.Scheduler.WebApi.csproj new file mode 100644 index 00000000000..1007026550f --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.WebApi/DurableTask.Scheduler.WebApi.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + + + + diff --git a/playground/DurableTask/DurableTask.Scheduler.WebApi/DurableTask.Scheduler.WebApi.http b/playground/DurableTask/DurableTask.Scheduler.WebApi/DurableTask.Scheduler.WebApi.http new file mode 100644 index 00000000000..669830c2c64 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.WebApi/DurableTask.Scheduler.WebApi.http @@ -0,0 +1,11 @@ +@HostAddress = http://localhost:5142 + +POST {{HostAddress}}/create +Content-Type: application/json + +{ + "text": "hello world" +} + +### + diff --git a/playground/DurableTask/DurableTask.Scheduler.WebApi/Program.cs b/playground/DurableTask/DurableTask.Scheduler.WebApi/Program.cs new file mode 100644 index 00000000000..92ff9cf8328 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.WebApi/Program.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddDurableTaskClient( + clientBuilder => + { + clientBuilder.UseDurableTaskScheduler( + builder.Configuration.GetConnectionString("taskhub") ?? throw new InvalidOperationException("Scheduler connection string not configured."), + options => + { + options.AllowInsecureCredentials = true; + }); + }); + +var app = builder.Build(); + +app.MapPost("/create", async (EchoValue value, DurableTaskClient durableTaskClient) => + { + string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync( + "Echo", + value); + + await durableTaskClient.WaitForInstanceCompletionAsync(instanceId); + + return Results.Ok(); + }) + .WithName("CreateOrchestration"); + +app.MapPost("/echo", ([FromBody] EchoValue value) => + { + return new EchoValue { Text = $"Echoed: {value.Text}" }; + }) + .WithName("EchoText"); + +app.Run(); + +public record EchoValue +{ + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.WebApi/Properties/launchSettings.json b/playground/DurableTask/DurableTask.Scheduler.WebApi/Properties/launchSettings.json new file mode 100644 index 00000000000..5f5caff3a97 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.WebApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5142", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.WebApi/appsettings.json b/playground/DurableTask/DurableTask.Scheduler.WebApi/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.WebApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/DurableTask.Scheduler.Worker.csproj b/playground/DurableTask/DurableTask.Scheduler.Worker/DurableTask.Scheduler.Worker.csproj new file mode 100644 index 00000000000..7b066f9480c --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/DurableTask.Scheduler.Worker.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + + + + diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/Program.cs b/playground/DurableTask/DurableTask.Scheduler.Worker/Program.cs new file mode 100644 index 00000000000..aad6f3b3cd5 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/Program.cs @@ -0,0 +1,35 @@ +using DurableTask.Scheduler.Worker.Tasks.Echo; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddServiceDiscovery(); + +builder + .Services + .AddHttpClient("Echo", + client => client.BaseAddress = new Uri("https+http://webapi")) + .AddServiceDiscovery(); + +builder.Services.AddDurableTaskWorker( + workerBuilder => + { + workerBuilder.AddTasks(r => + { + r.AddActivity("EchoActivity"); + r.AddOrchestrator("Echo"); + }); + workerBuilder.UseDurableTaskScheduler( + builder.Configuration.GetConnectionString("taskhub") ?? throw new InvalidOperationException("Scheduler connection string not configured."), + options => + { + options.AllowInsecureCredentials = true; + }); + }); + +var host = builder.Build(); + +host.Run(); diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/Properties/launchSettings.json b/playground/DurableTask/DurableTask.Scheduler.Worker/Properties/launchSettings.json new file mode 100644 index 00000000000..a2a6bf247e8 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "CommunityToolkit.Aspire.Hosting.DurableTask.Scheduler.Worker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoActivity.cs b/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoActivity.cs new file mode 100644 index 00000000000..65f7d7ed2f8 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoActivity.cs @@ -0,0 +1,18 @@ +using System.Net.Http.Json; +using Microsoft.DurableTask; + +namespace DurableTask.Scheduler.Worker.Tasks.Echo; + +sealed class EchoActivity(IHttpClientFactory clientFactory) : TaskActivity +{ + public override async Task RunAsync(TaskActivityContext context, string input) + { + HttpClient client = clientFactory.CreateClient("Echo"); + + var result = await client.PostAsync("/echo", JsonContent.Create(new EchoInput { Text = input })); + + var output = await result.Content.ReadFromJsonAsync(); + + return output?.Text ?? throw new InvalidOperationException("Invalid response from echo service!"); + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoInput.cs b/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoInput.cs new file mode 100644 index 00000000000..867a1bb87f1 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoInput.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace DurableTask.Scheduler.Worker.Tasks.Echo; + +sealed record EchoInput +{ + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoOrchestrator.cs b/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoOrchestrator.cs new file mode 100644 index 00000000000..7b5b1c11fe2 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/Tasks/Echo/EchoOrchestrator.cs @@ -0,0 +1,17 @@ +using Microsoft.DurableTask; + +namespace DurableTask.Scheduler.Worker.Tasks.Echo; + +sealed class EchoOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, EchoInput input) + { + string output = await context.CallActivityAsync("EchoActivity", input.Text); + + output = await context.CallActivityAsync("EchoActivity", output); + + output = await context.CallActivityAsync("EchoActivity", output); + + return output; + } +} diff --git a/playground/DurableTask/DurableTask.Scheduler.Worker/appsettings.json b/playground/DurableTask/DurableTask.Scheduler.Worker/appsettings.json new file mode 100644 index 00000000000..b2dcdb67421 --- /dev/null +++ b/playground/DurableTask/DurableTask.Scheduler.Worker/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskConstants.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskConstants.cs new file mode 100644 index 00000000000..a603081a7cc --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskConstants.cs @@ -0,0 +1,41 @@ +// 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; + +internal static class DurableTaskConstants +{ + public static class Scheduler + { + public static class Dashboard + { + public static readonly Uri Endpoint = new Uri("https://dashboard.durabletask.io"); + } + + public static class Emulator + { + public static class Container + { + /// mcr.microsoft.com/dts + public const string Registry = "mcr.microsoft.com/dts"; + + /// dts-emulator + public const string Image = "dts-emulator"; + + /// latest + public static string Tag => "latest"; + } + + public static class Endpoints + { + public const string Worker = "worker"; + public const string Dashboard = "dashboard"; + } + } + + public static class TaskHub + { + public const string DefaultName = "default"; + } + } +} 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..7a89fdc4cd4 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs @@ -0,0 +1,74 @@ +// 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; + +/// +/// Represents an indivdual task hub of a Durable Task Scheduler. +/// +/// The name of the task hub resource. +/// The scheduler to which the task hub belongs. +public class DurableTaskHubResource(string name, DurableTaskSchedulerResource parent) + : Resource(name), IResourceWithConnectionString, IResourceWithEndpoints, IResourceWithParent, IDurableTaskResourceWithDashboard +{ + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"{this.Parent.ConnectionStringExpression};TaskHub={this.ResolveTaskHubName()}"); + + /// + public DurableTaskSchedulerResource Parent => parent; + + /// + /// Gets or sets the name of the task hub (if different from the resource name). + /// + public string? TaskHubName { get; set; } + + ReferenceExpression IDurableTaskResourceWithDashboard.DashboardEndpointExpression => + this.GetDashboardEndpoint(); + + internal ReferenceExpression TaskHubNameExpression => + ReferenceExpression.Create($"{this.ResolveTaskHubName()}"); + + ReferenceExpression GetDashboardEndpoint() + { + var defaultValue = ReferenceExpression.Create($"default"); + + ReferenceExpressionBuilder builder = new(); + + builder.Append($"{this.ResolveDashboardEndpoint()}subscriptions/{this.ResolveSubscriptionId() ?? defaultValue}/schedulers/{this.Parent.SchedulerNameExpression}/taskhubs/{this.ResolveTaskHubName()}"); + + if (!this.Parent.IsEmulator) + { + // NOTE: The endpoint is expected to have the trailing slash. + builder.Append($"?endpoint={QueryParameterReference.Create(this.Parent.DashboardSchedulerEndpointExpression)}"); + } + + return builder.Build(); + } + + string ResolveTaskHubName() => this.TaskHubName ?? this.Name; + + ReferenceExpression ResolveDashboardEndpoint() + { + if (this.TryGetLastAnnotation(out var annotation) + && annotation.DashboardEndpoint is not null) + { + return annotation.DashboardEndpoint; + } + + return (this.Parent as IDurableTaskResourceWithDashboard).DashboardEndpointExpression; + } + + ReferenceExpression? ResolveSubscriptionId() + { + if (this.TryGetLastAnnotation(out var annotation) + && annotation.SubscriptionId is not null) + { + return annotation.SubscriptionId; + } + + return this.Parent.SubscriptionIdExpression; + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerAuthentication.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerAuthentication.cs new file mode 100644 index 00000000000..a399221636f --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerAuthentication.cs @@ -0,0 +1,28 @@ +// 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; + +/// +/// Represents the authentication methods supported by the Durable Task Scheduler. +/// +public static class DurableTaskSchedulerAuthentication +{ + /// + /// No authentication is used with the scheduler. + /// + /// + /// This is suitable only for local, emulator-based development. + /// + public const string None = "None"; + + /// + /// Use the developer's Azure account to authenticate with the scheduler. + /// + public const string Default = "DefaultAzure"; + + /// + /// Use managed identity to authenticate with the scheduler. + /// + public const string ManagedIdentity = "ManagedIdentity"; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerDashboardAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerDashboardAnnotation.cs new file mode 100644 index 00000000000..d4b18be2aa6 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerDashboardAnnotation.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.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +internal sealed class DurableTaskSchedulerDashboardAnnotation(ReferenceExpression? subscriptionId, ReferenceExpression? dashboardEndpoint) + : IResourceAnnotation +{ + public ReferenceExpression? DashboardEndpoint => dashboardEndpoint; + + public ReferenceExpression? SubscriptionId => subscriptionId; +} 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..710bbdd0ef0 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs @@ -0,0 +1,29 @@ +// 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; + +/// +/// Wraps an in a type that exposes container extension methods. +/// +/// The inner resource used to store annotations. +public sealed class DurableTaskSchedulerEmulatorResource(DurableTaskSchedulerResource innerResource) + : ContainerResource(innerResource.Name), IResource +{ + /// + public override ResourceAnnotationCollection Annotations => innerResource.Annotations; + + /// + public override string Name => innerResource.Name; + + /// + /// Gets or sets whether the emulator should use dynamic task hubs. + /// + /// + /// Using dynamic task hubs eliminates the requirement that they be pre-defined, + /// which can be useful when the same emulator instance is used across sessions. + /// + public bool UseDynamicTaskHubs { get; set; } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerExtensions.cs new file mode 100644 index 00000000000..c9461c25d85 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerExtensions.cs @@ -0,0 +1,441 @@ +// 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 System.Diagnostics; + +namespace Aspire.Hosting.Azure; + +/// +/// Provides extension methods for adding and configuring Durable Task Scheduler resources to the application model. +/// +public static class DurableTaskSchedulerExtensions +{ + /// + /// Adds a Durable Task Scheduler resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the resource. + /// (Optional) Callback that exposes the resource allowing for customization. + /// A reference to the . + public static IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, [ResourceName] string name, Action>? configure = null) + { + DurableTaskSchedulerResource resource = new(name); + + var resourceBuilder = builder.AddResource(resource); + + configure?.Invoke(resourceBuilder); + + return resourceBuilder; + } + + /// + /// Configures a Durable Task Scheduler resource to be emulated. + /// + /// The Durable Task Scheduler resource builder. + /// Callback that exposes the underlying container used for emulation allowing for customization. + /// A reference to the . + /// + /// This version of the package defaults to the tag of the container image in the registry. + /// + /// + /// The following example creates a Durable Task Scheduler resource that runs locally in an emulator and referencing that resource in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(); + /// + /// var taskHub = scheduler.AddDurableTaskHub("taskhub"); + /// + /// builder.AddProject<Projects.MyApp>("myapp") + /// .WithReference(taskHub); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) + { + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + builder + .WithDashboard(); + + // + // Add dashboards for existing task hubs (not already marked to have a dashboard annotation). + // + + var existingTaskHubs = + builder + .ApplicationBuilder + .Resources + .OfType() + .Where(taskHub => taskHub.Parent == builder.Resource) + .Where(taskHub => !taskHub.HasAnnotationOfType()); + + foreach (var taskHub in existingTaskHubs) + { + builder.ApplicationBuilder.CreateResourceBuilder(taskHub).WithDashboard(); + } + + var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(new DurableTaskSchedulerEmulatorResource(builder.Resource)) + .WithHttpEndpoint(name: DurableTaskConstants.Scheduler.Emulator.Endpoints.Worker, targetPort: 8080) + .WithHttpEndpoint(name: DurableTaskConstants.Scheduler.Emulator.Endpoints.Dashboard, targetPort: 8082) + .WithEnvironment( + async (EnvironmentCallbackContext context) => + { + var nameTasks = + builder + .ApplicationBuilder + .Resources + .OfType() + .Where(r => r.Parent == builder.Resource) + .Select(r => r.TaskHubNameExpression) + .Select(async r => await r.GetValueAsync(context.CancellationToken).ConfigureAwait(false)) + .ToList(); + + await Task.WhenAll(nameTasks).ConfigureAwait(false); + + var taskHubNames = nameTasks + .Select(r => r.Result) + .Where(r => r is not null) + .Distinct() + .ToList(); + + if (taskHubNames.Any()) + { + context.EnvironmentVariables.Add("DTS_TASK_HUB_NAMES", String.Join(",", taskHubNames)); + } + }) + .WithAnnotation( + new ContainerImageAnnotation + { + Registry = DurableTaskConstants.Scheduler.Emulator.Container.Registry, + Image = DurableTaskConstants.Scheduler.Emulator.Container.Image, + Tag = DurableTaskConstants.Scheduler.Emulator.Container.Tag + }); + + configureContainer?.Invoke(surrogateBuilder); + + if (surrogateBuilder.Resource.UseDynamicTaskHubs) + { + surrogateBuilder.WithEnvironment( + (EnvironmentCallbackContext context) => + { + context.EnvironmentVariables.Add("DTS_USE_DYNAMIC_TASK_HUBS", "true"); + }); + } + + builder.Resource.Authentication ??= DurableTaskSchedulerAuthentication.None; + + return builder; + } + + /// + /// Marks the resource as an existing Durable Task Scheduler instance when the application is running. + /// + /// The Durable Task Scheduler resource builder. + /// The connection string to the existing Durable Task Scheduler instance. + /// A reference to the . + /// + /// The following example creates a preexisting Durable Task Scheduler resource configured via external parameters and referencing that resource in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting(builder.AddParameter("scheduler-connection-string")); + /// + /// var taskHub = + /// scheduler.AddDurableTaskHub("taskhub") + /// .WithTaskHubName(builder.AddParameter("taskhub-name")); + /// + /// builder.AddProject<Projects.MyApp>("myapp") + /// .WithReference(taskHub); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, IResourceBuilder connectionString) + { + return builder.RunAsExisting(ReferenceExpression.Create($"{connectionString}")); + } + + /// + /// Marks the resource as an existing Durable Task Scheduler instance when the application is running. + /// + /// The Durable Task Scheduler resource builder. + /// The connection string to the existing Durable Task Scheduler instance. + /// A reference to the . + /// + /// The following example creates a preexisting Durable Task Scheduler resource configured via strings and referencing that resource in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// string connectionString = "..."; + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting(connectionString); + /// + /// string taskHubName = "..."; + /// + /// var taskHub = + /// scheduler.AddDurableTaskHub("taskhub") + /// .WithTaskHubName(taskHubName); + /// + /// builder.AddProject<Projects.MyApp>("myapp") + /// .WithReference(taskHub); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, string connectionString) + { + return builder.RunAsExisting(ReferenceExpression.Create($"{connectionString}")); + } + + /// + /// Marks the resource as an existing Durable Task Scheduler instance when the application is running. + /// + /// The Durable Task Scheduler resource builder. + /// The connection string to the existing Durable Task Scheduler instance. + /// A reference to the . + /// + /// The following example creates a preexisting Durable Task Scheduler resource configured via strings and referencing that resource in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var connectionString = ReferenceExpression.Create($"..."); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting(connectionString); + /// + /// string taskHubName = "..."; + /// + /// var taskHub = + /// scheduler.AddDurableTaskHub("taskhub") + /// .WithTaskHubName(taskHubName); + /// + /// builder.AddProject<Projects.MyApp>("myapp") + /// .WithReference(taskHub); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, ReferenceExpression connectionString) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new ExistingDurableTaskSchedulerAnnotation(connectionString)); + + var connectionStringParameters = ParseConnectionString(connectionString.ValueExpression); + + if (connectionStringParameters.TryGetValue("Endpoint", out string? endpoint)) + { + builder.Resource.SchedulerEndpoint ??= new Uri(endpoint); + } + + if (connectionStringParameters.TryGetValue("Authentication", out string? authentication)) + { + builder.Resource.Authentication ??= authentication; + } + } + + return builder; + } + + static IReadOnlyDictionary ParseConnectionString(string connectionString) + { + Dictionary dictionary = new(StringComparer.OrdinalIgnoreCase); + + var parameters = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var parameter in parameters) + { + var keyValue = parameter.Split('=', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (keyValue.Length != 2) + { + throw new ArgumentException($"Invalid connection string format: {parameter}"); + } + + dictionary[keyValue[0]] = keyValue[1]; + } + + return dictionary; + } + + /// + /// Adds a command to the resource that opens the Durable Task Scheduler Dashboard in a web browser. + /// + /// + /// + /// + public static IResourceBuilder WithDashboard(this IResourceBuilder builder, string dashboardEndpoint) + { + return builder.WithDashboard(ReferenceExpression.Create($"{dashboardEndpoint}")); + } + + /// + /// Adds a command to the resource that opens the Durable Task Scheduler Dashboard in a web browser. + /// + /// + /// + /// + public static IResourceBuilder WithDashboard(this IResourceBuilder builder, IResourceBuilder? dashboardEndpoint = null) + { + return builder.WithDashboard(dashboardEndpoint is not null ? ReferenceExpression.Create($"{dashboardEndpoint}") : null); + } + + static IResourceBuilder WithDashboard(this IResourceBuilder builder, ReferenceExpression? dashboardEndpoint) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new DurableTaskSchedulerDashboardAnnotation( + subscriptionId: null, + dashboardEndpoint: dashboardEndpoint)); + + builder.WithOpenDashboardCommand(isTaskHub: false); + } + + return builder; + } + + /// + /// Adds a Durable Task Scheduler task hub resource to the application model. + /// + /// The Durable Task Scheduler resource builder. + /// The name of the resource. + /// (Optional) Callback that exposes the resource allowing for customization. + /// A reference to the . + public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, [ResourceName] string name, Action>? configure = null) + { + DurableTaskHubResource taskHubResource = new(name, builder.Resource); + + var taskHubResourceBuilder = builder.ApplicationBuilder.AddResource(taskHubResource); + + configure?.Invoke(taskHubResourceBuilder); + + if (builder.Resource.IsEmulator) + { + taskHubResourceBuilder.WithDashboard(); + } + + return taskHubResourceBuilder; + } + + /// + /// Sets the name of the task hub if different from the resource name. + /// + /// The Durable Task Scheduler task hub resource builder. + /// The name of the task hub. + /// A reference to the . + public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, string name) + { + builder.Resource.TaskHubName = name; + + return builder; + } + + /// + /// Sets the name of the task hub if different from the resource name. + /// + /// The Durable Task Scheduler task hub resource builder. + /// The name of the task hub. + /// A reference to the . + public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, IResourceBuilder name) + { + builder.Resource.TaskHubName = name.Resource.Value; + + return builder; + } + + /// + /// Adds a command to the resource that opens the Durable Task Scheduler Dashboard for the task hub in a web browser. + /// + /// + /// + /// + /// + public static IResourceBuilder WithDashboard(this IResourceBuilder builder, string? dashboardEndpoint, string? subscriptionId) + { + return builder.WithDashboard( + dashboardEndpoint: dashboardEndpoint is not null ? ReferenceExpression.Create($"{dashboardEndpoint}") : null, + subscriptionId: subscriptionId is not null ? ReferenceExpression.Create($"{subscriptionId}") : null); + } + + /// + /// Adds a command to the resource that opens the Durable Task Scheduler Dashboard for the task hub in a web browser. + /// + /// + /// + /// + /// + public static IResourceBuilder WithDashboard(this IResourceBuilder builder, IResourceBuilder? dashboardEndpoint = null, IResourceBuilder? subscriptionId = null) + { + return builder.WithDashboard( + dashboardEndpoint: dashboardEndpoint is not null ? ReferenceExpression.Create($"{dashboardEndpoint}") : null, + subscriptionId: subscriptionId is not null ? ReferenceExpression.Create($"{subscriptionId}") : null); + } + + static IResourceBuilder WithDashboard(this IResourceBuilder builder, ReferenceExpression? dashboardEndpoint, ReferenceExpression? subscriptionId) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new DurableTaskSchedulerDashboardAnnotation( + subscriptionId: subscriptionId, + dashboardEndpoint: dashboardEndpoint)); + + builder.WithOpenDashboardCommand(isTaskHub: true); + } + + return builder; + } + + /// + /// Enables the use of dynamic task hubs for the emulator. + /// + /// The Durable Task Scheduler emulator resource builder. + /// A reference to the . + /// + /// Using dynamic task hubs eliminates the requirement that they be pre-defined, + /// which can be useful when the same emulator instance is used across sessions. + /// + public static IResourceBuilder WithDynamicTaskHubs(this IResourceBuilder builder) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.Resource.UseDynamicTaskHubs = true; + } + + return builder; + } + + static IResourceBuilder WithOpenDashboardCommand(this IResourceBuilder builder, bool isTaskHub) where T : IDurableTaskResourceWithDashboard + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithCommand( + isTaskHub ? "durabletask-hub-open-dashboard" : "durabletask-scheduler-open-dashboard", + "Open Dashboard", + async context => + { + var dashboardEndpoint = await builder.Resource.DashboardEndpointExpression.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + + Process.Start(new ProcessStartInfo { FileName = dashboardEndpoint, UseShellExecute = true }); + + return CommandResults.Success(); + }, + new() + { + Description = "Open the Durable Task Scheduler Dashboard", + IconName = "GlobeArrowForward", + IsHighlighted = isTaskHub, + }); + } + + return builder; + } +} 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..1e9b8c8ac3d --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -0,0 +1,150 @@ +// 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; + +/// +/// Represents a Durable Task Scheduler resource. +/// +/// The name of the resource. +public sealed class DurableTaskSchedulerResource(string name) + : Resource(name), IResourceWithConnectionString, IResourceWithEndpoints, IDurableTaskResourceWithDashboard +{ + EndpointReference EmulatorDashboardEndpoint => new(this, DurableTaskConstants.Scheduler.Emulator.Endpoints.Dashboard); + EndpointReference EmulatorSchedulerEndpoint => new(this, DurableTaskConstants.Scheduler.Emulator.Endpoints.Worker); + + /// + /// Gets or sets the authentication type used to access the scheduler. + /// + /// + /// The value should be from . + /// The default value is . + /// + public string? Authentication { get; set; } + + /// + /// Gets or sets the client ID used to access the scheduler, when using managed identity for authentication. + /// + public string? ClientId { get; set; } + + /// + public ReferenceExpression ConnectionStringExpression => + this.CreateConnectionString(); + + /// + /// Gets a value indicating whether the scheduler is running as a local emulator. + /// + public bool IsEmulator => this.IsContainer(); + + /// + /// Gets or sets the endpoint used by applications to access the scheduler. + /// + public Uri? SchedulerEndpoint { get; set; } + + /// + /// Gets or sets the name of the scheduler (if different from the resource name). + /// + public string? SchedulerName { get; set; } + + ReferenceExpression IDurableTaskResourceWithDashboard.DashboardEndpointExpression => + this.ResolveDashboardEndpoint(); + + internal ReferenceExpression DashboardSchedulerEndpointExpression => + this.ResolveDashboardSchedulerEndpoint(); + + internal ReferenceExpression SchedulerEndpointExpression => + this.ResolveSchedulerEndpoint(); + + internal ReferenceExpression? SubscriptionIdExpression => + this.ResolveSubscriptionId(); + + internal ReferenceExpression SchedulerNameExpression => + this.ResolveSchedulerName(); + + ReferenceExpression? ResolveSubscriptionId() + { + if (this.TryGetLastAnnotation(out DurableTaskSchedulerDashboardAnnotation? annotation) && annotation.SubscriptionId is not null) + { + return annotation.SubscriptionId; + } + + return null; + } + + ReferenceExpression ResolveSchedulerName() + { + if (this.SchedulerName is not null) + { + return ReferenceExpression.Create($"{this.SchedulerName}"); + } + + if (this.IsEmulator) + { + return ReferenceExpression.Create($"default"); + } + + return ReferenceExpression.Create($"{this.Name}"); + } + + ReferenceExpression CreateConnectionString() + { + if (this.TryGetLastAnnotation(out ExistingDurableTaskSchedulerAnnotation? annotation)) + { + return annotation.ConnectionString; + } + + string connectionString = $"Authentication={this.Authentication ?? DurableTaskSchedulerAuthentication.None}"; + + if (this.ClientId is not null) + { + connectionString += $";ClientID={this.ClientId}"; + } + + return ReferenceExpression.Create($"Endpoint={this.SchedulerEndpointExpression};{connectionString}"); + } + + ReferenceExpression ResolveDashboardEndpoint() + { + if (this.TryGetLastAnnotation(out DurableTaskSchedulerDashboardAnnotation? annotation) && annotation.DashboardEndpoint is not null) + { + // NOTE: Container endpoints do not include the trailing slash. + return ReferenceExpression.Create($"{annotation.DashboardEndpoint}/"); + } + + if (this.IsEmulator) + { + // NOTE: Container endpoints do not include the trailing slash. + return ReferenceExpression.Create($"{this.EmulatorDashboardEndpoint}/"); + } + + return ReferenceExpression.Create($"{DurableTaskConstants.Scheduler.Dashboard.Endpoint.ToString()}"); + } + + ReferenceExpression ResolveDashboardSchedulerEndpoint() + { + if (this.IsEmulator) + { + return ReferenceExpression.Create($"{this.EmulatorDashboardEndpoint}/api/"); + } + + return this.ResolveSchedulerEndpoint(); + } + + ReferenceExpression ResolveSchedulerEndpoint() + { + if (this.SchedulerEndpoint is not null) + { + return ReferenceExpression.Create($"{this.SchedulerEndpoint.ToString()}"); + } + + if (this.IsEmulator) + { + // NOTE: Container endpoints do not include the trailing slash. + return ReferenceExpression.Create($"{this.EmulatorSchedulerEndpoint}/"); + } + + throw new InvalidOperationException("Scheduler endpoint is not set."); + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/ExistingDurableTaskSchedulerAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/ExistingDurableTaskSchedulerAnnotation.cs new file mode 100644 index 00000000000..21f8a13cfe7 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/ExistingDurableTaskSchedulerAnnotation.cs @@ -0,0 +1,11 @@ +// 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; + +internal sealed class ExistingDurableTaskSchedulerAnnotation(ReferenceExpression connectionString) : IResourceAnnotation +{ + public ReferenceExpression ConnectionString => connectionString; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/IDurableTaskResourceWithDashboard.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/IDurableTaskResourceWithDashboard.cs new file mode 100644 index 00000000000..5e586601c5f --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/IDurableTaskResourceWithDashboard.cs @@ -0,0 +1,11 @@ +// 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; + +internal interface IDurableTaskResourceWithDashboard : IResource +{ + ReferenceExpression DashboardEndpointExpression { get; } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/QueryParameterReference.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/QueryParameterReference.cs new file mode 100644 index 00000000000..be4f6efb31c --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/QueryParameterReference.cs @@ -0,0 +1,33 @@ +// 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 System.Net; + +namespace Aspire.Hosting.Azure; + +/// +/// TODO: Drop this when https://github.com/dotnet/aspire/issues/3117 is resolved. +/// +internal sealed class QueryParameterReference : IValueProvider, IValueWithReferences, IManifestExpressionProvider +{ + public static QueryParameterReference Create(ReferenceExpression reference) => new(reference); + + private readonly ReferenceExpression _reference; + + private QueryParameterReference(ReferenceExpression reference) + { + this._reference = reference; + } + + IEnumerable IValueWithReferences.References => [this._reference]; + + public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + var value = await _reference.GetValueAsync(cancellationToken).ConfigureAwait(false); + + return WebUtility.UrlEncode(value); + } + + string IManifestExpressionProvider.ValueExpression => WebUtility.UrlEncode(_reference.ValueExpression); +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsDurableTaskTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsDurableTaskTests.cs new file mode 100644 index 00000000000..8047cb72740 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsDurableTaskTests.cs @@ -0,0 +1,273 @@ +// 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.Testing; +using Aspire.Hosting.Utils; +using Aspire.TestUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureFunctionsDurableTaskTests +{ + [Fact] + public async Task AddDurableTaskScheduler() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDurableTaskScheduler("scheduler"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var scheduler = model.Resources.OfType().Single(); + + Assert.Null(scheduler.Authentication); + Assert.Null(scheduler.ClientId); + Assert.False(scheduler.IsEmulator); + Assert.Null(scheduler.SchedulerEndpoint); + Assert.Null(scheduler.SchedulerName); + + await Assert.ThrowsAsync(async () => await scheduler.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); + await Assert.ThrowsAsync(async () => await scheduler.SchedulerEndpointExpression.GetValueAsync(CancellationToken.None)); + + Assert.Null(scheduler.SubscriptionIdExpression); + + Assert.Equal(DurableTaskConstants.Scheduler.Dashboard.Endpoint.ToString(), await (scheduler as IDurableTaskResourceWithDashboard).DashboardEndpointExpression.GetValueAsync(CancellationToken.None)); + await Assert.ThrowsAsync(async () => await scheduler.DashboardSchedulerEndpointExpression.GetValueAsync(CancellationToken.None)); + Assert.Equal("scheduler", await scheduler.SchedulerNameExpression.GetValueAsync(CancellationToken.None)); + } + + [Fact] + public async Task AddDurableTaskSchedulerWithConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddDurableTaskScheduler( + "scheduler", + options => + { + options.Resource.Authentication = "TestAuthentication"; + options.Resource.ClientId = "TestClientId"; + options.Resource.SchedulerEndpoint = new Uri("https://scheduler.test.io"); + options.Resource.SchedulerName = "TestSchedulerName"; + }); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var scheduler = model.Resources.OfType().Single(); + + Assert.Equal("TestAuthentication", scheduler.Authentication); + Assert.Equal("TestClientId", scheduler.ClientId); + Assert.False(scheduler.IsEmulator); + Assert.Equal(new Uri("https://scheduler.test.io"), scheduler.SchedulerEndpoint); + Assert.Equal("TestSchedulerName", scheduler.SchedulerName); + + Assert.Equal("Endpoint=https://scheduler.test.io/;Authentication=TestAuthentication;ClientID=TestClientId", await scheduler.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); + Assert.Equal("https://scheduler.test.io/", await scheduler.SchedulerEndpointExpression.GetValueAsync(CancellationToken.None)); + + Assert.Null(scheduler.SubscriptionIdExpression); + + Assert.Equal("https://dashboard.durabletask.io/".ToString(), await (scheduler as IDurableTaskResourceWithDashboard).DashboardEndpointExpression.GetValueAsync(CancellationToken.None)); + Assert.Equal("https://scheduler.test.io/", scheduler.DashboardSchedulerEndpointExpression.ValueExpression); + Assert.Equal("TestSchedulerName", await scheduler.SchedulerNameExpression.GetValueAsync(CancellationToken.None)); + } + + [Fact] + public async Task AddDurableTaskSchedulerAsExisting() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder + .AddDurableTaskScheduler("scheduler") + .RunAsExisting("Endpoint=https://scheduler.test.io/;Authentication=TestAuth"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var scheduler = model.Resources.OfType().Single(); + + Assert.Equal("TestAuth", scheduler.Authentication); + Assert.Null(scheduler.ClientId); + Assert.False(scheduler.IsEmulator); + Assert.Equal(new Uri("https://scheduler.test.io/"), scheduler.SchedulerEndpoint); + Assert.Null(scheduler.SchedulerName); + + Assert.Equal("Endpoint=https://scheduler.test.io/;Authentication=TestAuth", await scheduler.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); + Assert.Equal("https://scheduler.test.io/", await scheduler.SchedulerEndpointExpression.GetValueAsync(CancellationToken.None)); + + Assert.Null(scheduler.SubscriptionIdExpression); + + Assert.Equal("https://dashboard.durabletask.io/".ToString(), await (scheduler as IDurableTaskResourceWithDashboard).DashboardEndpointExpression.GetValueAsync(CancellationToken.None)); + Assert.Equal("https://scheduler.test.io/", scheduler.DashboardSchedulerEndpointExpression.ValueExpression); + Assert.Equal("scheduler", await scheduler.SchedulerNameExpression.GetValueAsync(CancellationToken.None)); + } + + [Fact] + public async Task AddDurableTaskSchedulerAsEmulator() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder + .AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var scheduler = model.Resources.OfType().Single(); + + Assert.Equal("None", scheduler.Authentication); + Assert.Null(scheduler.ClientId); + Assert.True(scheduler.IsEmulator); + Assert.Null(scheduler.SchedulerEndpoint); + Assert.Null(scheduler.SchedulerName); + + Assert.Equal("Endpoint={scheduler.bindings.worker.url}/;Authentication=None", scheduler.ConnectionStringExpression.ValueExpression); + Assert.Equal("{scheduler.bindings.worker.url}/", scheduler.SchedulerEndpointExpression.ValueExpression); + + Assert.Null(scheduler.SubscriptionIdExpression); + + Assert.Equal("{scheduler.bindings.dashboard.url}/", (scheduler as IDurableTaskResourceWithDashboard).DashboardEndpointExpression.ValueExpression); + Assert.Equal("{scheduler.bindings.dashboard.url}/api/", scheduler.DashboardSchedulerEndpointExpression.ValueExpression); + Assert.Equal("default", await scheduler.SchedulerNameExpression.GetValueAsync(CancellationToken.None)); + + Assert.True(scheduler.TryGetLastAnnotation(out var imageAnnotation)); + + Assert.Equal("mcr.microsoft.com/dts", imageAnnotation.Registry); + Assert.Equal("dts-emulator", imageAnnotation.Image); + Assert.Equal("latest", imageAnnotation.Tag); + + Assert.True(scheduler.TryGetEnvironmentVariables(out var environmentVariables)); + + EnvironmentCallbackContext context = new(builder.ExecutionContext); + + foreach (var environmentVariable in environmentVariables) + { + await environmentVariable.Callback(context); + } + + // NOTE: If no task hub names are specified, no variable should be set as the default task hub name. + Assert.False(context.EnvironmentVariables.TryGetValue("DTS_TASK_HUB_NAMES", out var taskHubNames)); + } + + [Fact] + public async Task AddDurableTaskSchedulerAsEmulatorWithDynamicTaskhubs() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var schedulerBuilder = builder + .AddDurableTaskScheduler("scheduler") + .RunAsEmulator( + options => + { + options.WithDynamicTaskHubs(); + }); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var scheduler = model.Resources.OfType().Single(); + + Assert.True(scheduler.TryGetEnvironmentVariables(out var environmentVariables)); + + EnvironmentCallbackContext context = new(builder.ExecutionContext); + + foreach (var environmentVariable in environmentVariables) + { + await environmentVariable.Callback(context); + } + + Assert.True(context.EnvironmentVariables.TryGetValue("DTS_USE_DYNAMIC_TASK_HUBS", out var useDynamicTaskHubs)); + Assert.Equal("true", useDynamicTaskHubs); + } + + [Fact] + public async Task AddDurableTaskSchedulerAsEmulatorWithTaskhub() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var schedulerBuilder = builder + .AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + + schedulerBuilder.AddTaskHub("taskhub1"); + schedulerBuilder.AddTaskHub("taskhub2").WithTaskHubName("taskhub2a"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + var scheduler = model.Resources.OfType().Single(); + + Assert.True(scheduler.TryGetEnvironmentVariables(out var environmentVariables)); + + EnvironmentCallbackContext context = new(builder.ExecutionContext); + + foreach (var environmentVariable in environmentVariables) + { + await environmentVariable.Callback(context); + } + + Assert.True(context.EnvironmentVariables.TryGetValue("DTS_TASK_HUB_NAMES", out var taskHubNameString)); + + var taskHubNames = + taskHubNameString + .ToString() + !.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .OrderBy(x => x); + + Assert.Equal(taskHubNames, [ "taskhub1", "taskhub2a" ]); + + var taskHub1 = model.Resources.OfType().Single(x => x.Name == "taskhub1"); + + Assert.Equal("Endpoint={scheduler.bindings.worker.url}/;Authentication=None;TaskHub=taskhub1", taskHub1.ConnectionStringExpression.ValueExpression); + Assert.Equal("{scheduler.bindings.dashboard.url}/subscriptions/default/schedulers/default/taskhubs/taskhub1", (taskHub1 as IDurableTaskResourceWithDashboard).DashboardEndpointExpression.ValueExpression); + + var taskHub2 = model.Resources.OfType().Single(x => x.Name == "taskhub2"); + + Assert.Equal("Endpoint={scheduler.bindings.worker.url}/;Authentication=None;TaskHub=taskhub2a", taskHub2.ConnectionStringExpression.ValueExpression); + Assert.Equal("{scheduler.bindings.dashboard.url}/subscriptions/default/schedulers/default/taskhubs/taskhub2a", (taskHub2 as IDurableTaskResourceWithDashboard).DashboardEndpointExpression.ValueExpression); + } + + [RequiresDocker] + [Fact] + public async Task ResourceStartsAndRespondsOk() + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + using var builder = TestDistributedApplicationBuilder.Create(); + + var scheduler = + builder.AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + await app.ResourceNotifications.WaitForResourceAsync(scheduler.Resource.Name, KnownResourceStates.Running, cts.Token); + + using var client = app.CreateHttpClient(scheduler.Resource.Name, "dashboard"); + + using var response = await client.SendAsync( + new() + { + Headers = + { + { "x-taskhub", "default" }, + }, + RequestUri = new Uri("/api/v1/taskhubs/ping", UriKind.Relative), + Method = HttpMethod.Get + }, cts.Token); + + response.EnsureSuccessStatusCode(); + } +} \ No newline at end of file