From b5bf21652ff7f968c1071c622ad4f3122ad09a7f Mon Sep 17 00:00:00 2001 From: Cecil Phillip Date: Sat, 21 Feb 2026 10:23:04 -0500 Subject: [PATCH 1/3] Add sample Temporal integration for Aspire --- TemporalioSamples.sln | 78 ++++++ src/AspireIntegrations/.aspire/settings.json | 3 + src/AspireIntegrations/README.md | 219 +++++++++++++++ .../Temporal.Extensions.Aspire.Hosting.csproj | 16 ++ .../TemporalCliServerResource.cs | 17 ++ .../TemporalCliServerResourceExtensions.cs | 125 +++++++++ .../TemporalContainerBuilderExtensions.cs | 127 +++++++++ .../TemporalContainerOptions.cs | 6 + .../TemporalContainerResource.cs | 14 + .../TemporalHealthCheck.cs | 20 ++ .../TemporalHealthCheckBuilderExtensions.cs | 23 ++ .../TemporalLocalResource.cs | 11 + .../TemporalLocalResourceExtensions.cs | 250 ++++++++++++++++++ .../TemporalLocalResourceSubscriber.cs | 148 +++++++++++ .../TemporalResourceConstants.cs | 18 ++ .../TemporalResourceOptions.cs | 69 +++++ .../AppHost.cs | 15 ++ .../Properties/launchSettings.json | 31 +++ .../TemporalioSamples.SampleAppHost.csproj | 22 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../TemporalioSamples.SampleClient/Program.cs | 24 ++ .../TemporalioSamples.SampleClient.csproj | 14 + .../TemporalioSamples.SampleWorker/Program.cs | 20 ++ .../TemporalioSamples.SampleWorker.csproj | 21 ++ .../SimpleActivities.cs | 15 ++ .../SimpleWorkflow.cs | 16 ++ .../TemporalioSamples.SampleWorkflow.csproj | 8 + 28 files changed, 1347 insertions(+) create mode 100644 src/AspireIntegrations/.aspire/settings.json create mode 100644 src/AspireIntegrations/README.md create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerBuilderExtensions.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs create mode 100644 src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs create mode 100644 src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index 0189a4b..00a987e 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -107,6 +107,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.NexusCanc EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "NexusCancellation", "NexusCancellation", "{7123C63D-3158-4C9A-8EAD-6D4F1295BC04}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleWorkflow", "src\AspireIntegrations\TemporalioSamples.SampleWorkflow\TemporalioSamples.SampleWorkflow.csproj", "{6BA6C609-6D33-425B-883F-88ECE2E3DDB9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspireIntegrations", "AspireIntegrations", "{8781BE47-D710-408E-B143-4D5E20C356E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleWorker", "src\AspireIntegrations\TemporalioSamples.SampleWorker\TemporalioSamples.SampleWorker.csproj", "{FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleClient", "src\AspireIntegrations\TemporalioSamples.SampleClient\TemporalioSamples.SampleClient.csproj", "{035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleAppHost", "src\AspireIntegrations\TemporalioSamples.SampleAppHost\TemporalioSamples.SampleAppHost.csproj", "{CA136E75-FC34-44E1-B8B2-6E33D8AF520E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporal.Extensions.Aspire.Hosting", "src\AspireIntegrations\Temporal.Extensions.Aspire.Hosting\Temporal.Extensions.Aspire.Hosting.csproj", "{89D196AD-A6CE-42FB-BF46-C80BF579FE20}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -633,6 +645,66 @@ Global {6D0BE4C4-9C4F-4A3D-78F1-B0B761568559}.Release|x64.Build.0 = Release|Any CPU {6D0BE4C4-9C4F-4A3D-78F1-B0B761568559}.Release|x86.ActiveCfg = Release|Any CPU {6D0BE4C4-9C4F-4A3D-78F1-B0B761568559}.Release|x86.Build.0 = Release|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x64.ActiveCfg = Debug|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x64.Build.0 = Debug|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x86.ActiveCfg = Debug|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x86.Build.0 = Debug|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|Any CPU.Build.0 = Release|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x64.ActiveCfg = Release|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x64.Build.0 = Release|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x86.ActiveCfg = Release|Any CPU + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x86.Build.0 = Release|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x64.Build.0 = Debug|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x86.Build.0 = Debug|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|Any CPU.Build.0 = Release|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x64.ActiveCfg = Release|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x64.Build.0 = Release|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x86.ActiveCfg = Release|Any CPU + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x86.Build.0 = Release|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x64.Build.0 = Debug|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x86.Build.0 = Debug|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|Any CPU.Build.0 = Release|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x64.ActiveCfg = Release|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x64.Build.0 = Release|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x86.ActiveCfg = Release|Any CPU + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x86.Build.0 = Release|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x64.Build.0 = Debug|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x86.Build.0 = Debug|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|Any CPU.Build.0 = Release|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x64.ActiveCfg = Release|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x64.Build.0 = Release|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x86.ActiveCfg = Release|Any CPU + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x86.Build.0 = Release|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x64.ActiveCfg = Debug|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x64.Build.0 = Debug|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x86.ActiveCfg = Debug|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x86.Build.0 = Debug|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|Any CPU.Build.0 = Release|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x64.ActiveCfg = Release|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x64.Build.0 = Release|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x86.ActiveCfg = Release|Any CPU + {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -686,5 +758,11 @@ Global {AF077751-E4B9-4696-93CB-74653F0BB6C4} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {6D0BE4C4-9C4F-4A3D-78F1-B0B761568559} = {7123C63D-3158-4C9A-8EAD-6D4F1295BC04} {7123C63D-3158-4C9A-8EAD-6D4F1295BC04} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {8781BE47-D710-408E-B143-4D5E20C356E2} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {6BA6C609-6D33-425B-883F-88ECE2E3DDB9} = {8781BE47-D710-408E-B143-4D5E20C356E2} + {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398} = {8781BE47-D710-408E-B143-4D5E20C356E2} + {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8} = {8781BE47-D710-408E-B143-4D5E20C356E2} + {CA136E75-FC34-44E1-B8B2-6E33D8AF520E} = {8781BE47-D710-408E-B143-4D5E20C356E2} + {89D196AD-A6CE-42FB-BF46-C80BF579FE20} = {8781BE47-D710-408E-B143-4D5E20C356E2} EndGlobalSection EndGlobal diff --git a/src/AspireIntegrations/.aspire/settings.json b/src/AspireIntegrations/.aspire/settings.json new file mode 100644 index 0000000..a77ffc4 --- /dev/null +++ b/src/AspireIntegrations/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj" +} \ No newline at end of file diff --git a/src/AspireIntegrations/README.md b/src/AspireIntegrations/README.md new file mode 100644 index 0000000..2fa54ec --- /dev/null +++ b/src/AspireIntegrations/README.md @@ -0,0 +1,219 @@ +# Temporal Extensions for .NET Aspire + +## Overview + +This project provides custom Aspire resource definitions that enable developers to integrate Temporal workflow servers into their Aspire applications with minimal configuration. It supports three deployment models: + +- **Local Testing** - Temporal server using `Temporalio.Testing.WorkflowEnvironment` for fast local development and testing +- **Container-based** - Docker container running the official Temporal server image for development and staging environments +- **CLI-based** - Temporal CLI server for environments where Docker isn't available + +### Key Features + +- ✅ **Service Discovery** - Automatic environment variable injection for dependent services +- ✅ **Health Checks** - Built-in health checks integrated into Aspire's health pipeline +- ✅ **Resource Management** - Start/Stop commands in the Aspire dashboard with proper state management + +## Prerequisites + +### Required +- **.NET 10.0** or later +- **Aspire 13.0** or later + +### For Container-based Setup +- **Docker** - Required to run the Temporal container +- **Docker image** - `temporalio/temporal:latest` (automatically pulled) + +### For CLI-based Setup +- **Temporal CLI** - Install via [Temporal CLI documentation](https://docs.temporal.io/cli/install) + +## Quick Start + +### Local Server Setup + +The local server setup uses a Temporal environment for fast testing without external dependencies. It will download and run the necessary Temporal server binaries. + +**AppHost.cs:** +```csharp +using Temporal.Extensions.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add Temporal local server +var temporal = builder.AddTemporalLocalTestServer(); + +// Add a worker project that depends on Temporal +builder.AddProject("worker") + .WaitFor(temporal) + .WithReference(temporal); + +builder.Build().Run(); +``` + +**Advanced Configuration:** +```csharp +var temporal = builder.AddTemporalLocalTestServer(configure: options => +{ + // Port configuration + options.UIPort = 8233; // Web UI port + options.MetricsPort = 9233; // Metrics endpoint port + + // Network binding + options.TargetHost = "0.0.0.0:7233"; + + // Namespace configuration + options.Namespace = "default"; + options.AdditionalNamespaces = ["orders", "analytics"]; + + // Use existing Temporal server binary + options.DevServerOptions.ExistingPath = "/usr/local/bin/temporal"; + + // UI configuration + options.UI = true; + + // Search attributes for custom workflows + options.SearchAttributes = new[] + { + new SearchAttribute { Name = "Environment", ValueType = "Text" }, + new SearchAttribute { Name = "UserId", ValueType = "Text" }, + new SearchAttribute { Name = "ProcessingTime", ValueType = "Int" } + }; + + // Dynamic configuration values + options.DynamicConfigValues = [ + "persistence.cassandra.hosts = cassandra-host:9042" + ]; +}); +``` + +> Use `ExistingPath` to leverage a pre-installed Temporal binary + +--- + +### Container-based Setup + +Deploy Temporal using the CLI Docker container. + +**AppHost.cs:** +```csharp +using Temporal.Extensions.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add Temporal as a Docker container +var temporal = builder.AddTemporalDevContainer( + configure: options => + { + options.ImageTag = "latest"; // Use specific version if needed + options.UI = true; // Enable Web UI + options.Namespace = "default"; + }); + +// Add dependent projects that require Temporal +builder.AddProject("worker") + .WaitFor(temporal) + .WithReference(temporal); + +builder.Build().Run(); +``` + +**Advanced Configuration:** +```csharp +var temporal = builder.AddTemporalDevContainer(configure: options => +{ + // Network configuration + options.TargetHost = "127.0.0.1:7233"; + + // Namespaces + options.AdditionalNamespaces = ["default", "custom-ns"]; + + // Logging + options.DevServerOptions.LogLevel = "info"; + options.DevServerOptions.LogFormat = "json"; + + // Search attributes for custom workflows + options.SearchAttributes = new[] + { + new SearchAttribute { Name = "CustomField", ValueType = "Text" }, + new SearchAttribute { Name = "CustomInt", ValueType = "Int" } + }; +}); +``` +--- + +### CLI-based Setup + +Use the Temporal CLI server for environments without Docker. + +**AppHost.cs:** +```csharp +using Temporal.Extensions.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add Temporal CLI server +var temporal = builder.AddTemporalCliServer( + configure: options => + { + options.Namespace = "default"; + options.UI = true; + }); + +// Add dependent projects that require Temporal +builder.AddProject("worker") + .WaitFor(temporal) + .WithReference(temporal); + +builder.Build().Run(); +``` + +**Requirements:** +- Temporal CLI must be installed and available in PATH +- Run `temporal --version` to verify installation + +--- + +### Connection Strings + +Dependent projects can access Temporal connection information via environment variables: + +```csharp +var temporalAddress = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS"); +var temporalUiAddress = Environment.GetEnvironmentVariable("TEMPORAL_UI_ADDRESS"); +``` +--- + +## Configuration Options + +### TemporalResourceOptions + +The base configuration class for all resource types: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `Namespace` | string | "default" | Primary namespace for workflows | +| `AdditionalNamespaces` | List | ["default"] | Additional namespaces to register | +| `Port` | int | 7233 | gRPC service port | +| `UIPort` | int | 8233 | Web UI port | +| `MetricsPort` | int | 9233 | Metrics endpoint port | +| `UI` | bool | true | Enable Web UI | +| `TargetHost` | string | "0.0.0.0:7233" | Bind address (IP:port format) | +| `SearchAttributes` | List | null | Custom search attributes | +| `DynamicConfigValues` | List | [] | Dynamic configuration | +| `CodecEndpoint` | string | null | Codec server endpoint | +| `CodecAuth` | string | null | Codec authentication token | + +### Container-specific Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `ImageTag` | string | "latest" | Docker image tag version | + +--- + +## Resources + +- [Temporal Documentation](https://docs.temporal.io/) +- [Temporal .NET SDK](https://github.com/temporalio/sdk-dotnet) +- [.NET Aspire](https://learn.microsoft.com/dotnet/aspire) +- [Aspire Integrations](https://learn.microsoft.com/dotnet/aspire/integrations) diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj new file mode 100644 index 0000000..5b31036 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + $(NoWarn);SA1010;SA1116;SA1413;SA1117;CA1031;SA1503;CS0109;CA1002;CA2227;CA1305 + + + + + + + + + diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs new file mode 100644 index 0000000..13ee9eb --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs @@ -0,0 +1,17 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalCliServerResource(string name, string workingDirectory = "./") + : ExecutableResource(name, "temporal", workingDirectory), IResourceWithConnectionString, + IResourceWithServiceDiscovery +{ + private EndpointReference? primaryEndpoint; + + public EndpointReference PrimaryEndpoint => + primaryEndpoint ??= new(this, TemporalResourceConstants.ServiceEndpointName); + + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.Url)}"); + + public TemporalResourceOptions Options { get; set; } = new(); +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs new file mode 100644 index 0000000..e85e1a0 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.DependencyInjection; +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +public static class TemporalCliServerResourceExtensions +{ + public static IResourceBuilder AddTemporalCliServer( + this IDistributedApplicationBuilder builder, + string name = "temporal-cli-server", + Action? configure = null) + { + var resource = new TemporalCliServerResource(name); + configure?.Invoke(resource.Options); + + string? endpointAddress = null; + builder.Eventing.Subscribe(resource, async (@event, _) => + { + if (@event.Resource.TryGetEndpoints(out var endpoints)) + { + var serviceEndpoint = endpoints.Single(e => e.Name == TemporalResourceConstants.ServiceEndpointName); + endpointAddress = $"{serviceEndpoint.TargetHost}:{serviceEndpoint.Port}"; + } + + await Task.CompletedTask; + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .AddTemporalHealthCheck( + _ => new TemporalClientConnectOptions + { + Namespace = resource.Options.AdditionalNamespaces.First(), TargetHost = endpointAddress + }, healthCheckKey); + + return builder.AddResource(resource) + .WithArgs(BuildContainerArgs(resource.Options)) + .ExcludeFromManifest().WithEndpoint( + targetPort: resource.Options.Port, + isProxied: false, + name: TemporalResourceConstants.ServiceEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.UIPort, + isProxied: false, + name: TemporalResourceConstants.UIEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.MetricsPort, + isProxied: false, + name: TemporalResourceConstants.MetricsEndpointName) + .WithHealthCheck(healthCheckKey) + .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url => + { + url.DisplayText = "Dashboard"; + }); + } + + public static IResourceBuilder WithReference( + this IResourceBuilder builder, IResourceBuilder source) + where TDestination : IResourceWithEnvironment + { + return builder + .WithReference(source as IResourceBuilder) + .WithEnvironment(ctx => + { + ctx.EnvironmentVariables["TEMPORAL_ADDRESS"] = + source.Resource.PrimaryEndpoint.Property(EndpointProperty.HostAndPort); + + ctx.EnvironmentVariables["TEMPORAL_UI_ADDRESS"] = + source.GetEndpoint(TemporalResourceConstants.UIEndpointName); + + if (!string.IsNullOrEmpty(source.Resource.Options.CodecAuth)) + { + ctx.EnvironmentVariables["TEMPORAL_CODEC_AUTH"] = source.Resource.Options.CodecAuth; + } + + if (!string.IsNullOrEmpty(source.Resource.Options.CodecEndpoint)) + { + ctx.EnvironmentVariables["TEMPORAL_CODEC_ENDPOINT"] = source.Resource.Options.CodecEndpoint; + } + }); + } + + private static string[] BuildContainerArgs(TemporalResourceOptions options) + { + var args = new List { "server", "start-dev" }; + + // Bind to the configured IP + args.AddRange(["--ip", options.Ip]); + args.AddRange(["--port", options.Port.ToString()]); + args.AddRange(["--ui-port", options.UIPort.ToString()]); + + if (options.IsHeadless) + args.Add("--headless"); + + args.AddRange(["--log-level", options.DevServerOptions.LogLevel]); + + args.AddRange(["--log-format", options.DevServerOptions.LogFormat]); + + foreach (var ns in options.AdditionalNamespaces) + args.AddRange(["--namespace", ns]); + + // Add search attributes from inherited property + if (options.SearchAttributes != null) + { + foreach (var sa in options.SearchAttributes) + { + args.AddRange(["--search-attribute", $"{sa.Name}={sa.ValueType}"]); + } + } + + foreach (var dv in options.DynamicConfigValues) + args.AddRange(["--dynamic-config-value", dv]); + + if (!string.IsNullOrEmpty(options.CodecAuth)) + args.AddRange(["--codec-auth", options.CodecAuth]); + + if (!string.IsNullOrEmpty(options.CodecEndpoint)) + args.AddRange(["--codec-endpoint", options.CodecEndpoint]); + + if (!string.IsNullOrEmpty(options.ApiKey)) + args.AddRange(["--api-key", options.ApiKey]); + + return args.ToArray(); + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerBuilderExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerBuilderExtensions.cs new file mode 100644 index 0000000..8de8f20 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerBuilderExtensions.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.DependencyInjection; +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +public static class TemporalContainerBuilderExtensions +{ + public static IResourceBuilder AddTemporalDevContainer( + this IDistributedApplicationBuilder builder, + string name = "temporal-container", + Action? configure = null) + { + var resource = new TemporalContainerResource(name); + configure?.Invoke(resource.Options); + + string? endpointAddress = null; + builder.Eventing.Subscribe(resource, async (@event, _) => + { + if (@event.Resource.TryGetEndpoints(out var endpoints)) + { + var serviceEndpoint = endpoints.Single(e => e.Name == TemporalResourceConstants.ServiceEndpointName); + endpointAddress = $"{serviceEndpoint.TargetHost}:{serviceEndpoint.Port}"; + } + + await Task.CompletedTask; + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .AddTemporalHealthCheck( + _ => new TemporalClientConnectOptions + { + Namespace = resource.Options.Namespace, TargetHost = endpointAddress + }, healthCheckKey); + + return builder.AddResource(resource) + .WithImage(TemporalResourceConstants.TemporalImage, + resource.Options.ImageTag ?? TemporalResourceConstants.DefaultTag) + .WithImageRegistry("docker.io") + .WithArgs(BuildContainerArgs(resource.Options)) + .ExcludeFromManifest() + .WithEndpoint( + targetPort: TemporalResourceConstants.DefaultServiceEndpointPort, + port: resource.Options.Port, + name: TemporalResourceConstants.ServiceEndpointName) + .WithHttpEndpoint( + targetPort: TemporalResourceConstants.DefaultUIEndpointPort, + port: resource.Options.UIPort, + name: TemporalResourceConstants.UIEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.MetricsPort, + port: resource.Options.MetricsPort, + name: TemporalResourceConstants.MetricsEndpointName) + .WithHealthCheck(healthCheckKey) + .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url => + { + url.DisplayText = "Dashboard"; + }); + } + + public static IResourceBuilder WithReference( + this IResourceBuilder builder, IResourceBuilder source) + where TDestination : IResourceWithEnvironment + { + return builder + .WithReference(source as IResourceBuilder) + .WithEnvironment(ctx => + { + ctx.EnvironmentVariables["TEMPORAL_ADDRESS"] = + source.Resource.PrimaryEndpoint.Property(EndpointProperty.HostAndPort); + + ctx.EnvironmentVariables["TEMPORAL_UI_ADDRESS"] = + source.GetEndpoint(TemporalResourceConstants.UIEndpointName); + + if (!string.IsNullOrEmpty(source.Resource.Options.CodecAuth)) + { + ctx.EnvironmentVariables["TEMPORAL_CODEC_AUTH"] = source.Resource.Options.CodecAuth; + } + + if (!string.IsNullOrEmpty(source.Resource.Options.CodecEndpoint)) + { + ctx.EnvironmentVariables["TEMPORAL_CODEC_ENDPOINT"] = source.Resource.Options.CodecEndpoint; + } + }); + } + + private static string[] BuildContainerArgs(TemporalResourceOptions options) + { + var args = new List { "server", "start-dev" }; + + args.AddRange(["--ip", "0.0.0.0"]); + args.AddRange(["--port", $"{TemporalResourceConstants.DefaultServiceEndpointPort}"]); + + if (options.IsHeadless) + args.Add("--headless"); + + args.AddRange(["--log-level", options.DevServerOptions.LogLevel]); + + args.AddRange(["--log-format", options.DevServerOptions.LogFormat]); + + foreach (var ns in options.AdditionalNamespaces) + args.AddRange(["--namespace", ns]); + + // Add search attributes from inherited property + if (options.SearchAttributes != null) + { + foreach (var sa in options.SearchAttributes) + { + args.AddRange(["--search-attribute", $"{sa.Name}={sa.ValueType}"]); + } + } + + foreach (var dv in options.DynamicConfigValues) + args.AddRange(["--dynamic-config-value", dv]); + + if (!string.IsNullOrEmpty(options.CodecAuth)) + args.AddRange(["--codec-auth", options.CodecAuth]); + + if (!string.IsNullOrEmpty(options.CodecEndpoint)) + args.AddRange(["--codec-endpoint", options.CodecEndpoint]); + + if (!string.IsNullOrEmpty(options.ApiKey)) + args.AddRange(["--api-key", options.ApiKey]); + + return args.ToArray(); + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs new file mode 100644 index 0000000..cadc0fd --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs @@ -0,0 +1,6 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalContainerOptions : TemporalResourceOptions +{ + public string? ImageTag { get; set; } = TemporalResourceConstants.DefaultTag; +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs new file mode 100644 index 0000000..6f10079 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs @@ -0,0 +1,14 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalContainerResource(string name) : ContainerResource(name), IResourceWithConnectionString, IResourceWithServiceDiscovery +{ + private EndpointReference? primaryEndpoint; + + public EndpointReference PrimaryEndpoint => primaryEndpoint ??= new(this, TemporalResourceConstants.ServiceEndpointName); + + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + public TemporalContainerOptions Options { get; set; } = new(); +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs new file mode 100644 index 0000000..9944eeb --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalHealthCheck(TemporalClientConnectOptions clientConnectOptions) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await TemporalClient.ConnectAsync(clientConnectOptions); + return HealthCheckResult.Healthy(); + } + catch (Exception e) + { + return HealthCheckResult.Unhealthy("Unable to connect to Temporal server", e); + } + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs new file mode 100644 index 0000000..aa13c3d --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +public static class TemporalHealthCheckBuilderExtensions +{ + public static IHealthChecksBuilder AddTemporalHealthCheck( + this IHealthChecksBuilder builder, + Func clientConnectOptionsFactory, + string name = "temporal", + IEnumerable? tags = null, + TimeSpan? timeout = null) + { + return builder.Add(new HealthCheckRegistration( + name, + sp => new TemporalHealthCheck(clientConnectOptionsFactory(sp)), + HealthStatus.Unhealthy, + tags, + timeout)); + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs new file mode 100644 index 0000000..7e9010c --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs @@ -0,0 +1,11 @@ +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalLocalResource(string name) + : Resource(name), IResourceWithServiceDiscovery +{ + public WorkflowEnvironment? WorkflowEnvironment { get; set; } + + public TemporalResourceOptions Options { get; set; } = new(); +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs new file mode 100644 index 0000000..538953e --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs @@ -0,0 +1,250 @@ +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +public static class TemporalLocalResourceExtensions +{ + public static IResourceBuilder AddTemporalLocalTestServer( + this IDistributedApplicationBuilder builder, + string name = "temporal-local", + Action? configure = null) + { + builder.Services.TryAddEventingSubscriber(); + + var resource = new TemporalLocalResource(name); + + configure?.Invoke(resource.Options); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .AddTemporalHealthCheck(_ => new TemporalClientConnectOptions + { + Namespace = resource.Options.Namespace, + TargetHost = resource.WorkflowEnvironment?.Client.Connection.Options.TargetHost + }, healthCheckKey); + + var resourceBuilder = builder.AddResource(resource) + .ExcludeFromManifest() + .WithEndpoint( + targetPort: resource.Options.Port, + port: resource.Options.Port, + isProxied: false, + name: TemporalResourceConstants.ServiceEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.UIPort, + isProxied: false, + name: TemporalResourceConstants.UIEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.MetricsPort, + isProxied: false, + name: TemporalResourceConstants.MetricsEndpointName) + .WithHealthCheck(healthCheckKey) + .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url => { url.DisplayText = "Dashboard"; }) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "temporal-local", + CreationTimeStamp = DateTime.UtcNow, + State = KnownResourceStates.NotStarted, + Properties = + [ + new(CustomResourceKnownProperties.Source, "Temporalio.Testing.WorkflowEnvironment") + ] + }); + + resourceBuilder.WithCommand( + name: KnownResourceCommands.StopCommand, + displayName: "Stop", + executeCommand: async context => + { + var notifications = context.ServiceProvider + .GetRequiredService(); + var resourceLogger = context.ServiceProvider + .GetRequiredService() + .GetLogger(resource); + var eventing = context.ServiceProvider + .GetRequiredService(); + + await notifications.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Stopping + }); + + try + { + if (resource.WorkflowEnvironment != null) + { + resourceLogger.LogInformation("Shutting down Temporal test server '{ResourceName}'...", resource.Name); + await resource.WorkflowEnvironment.ShutdownAsync(); + resource.WorkflowEnvironment = null; + resourceLogger.LogInformation("Temporal test server '{ResourceName}' shut down successfully.", resource.Name); + } + + // Publish ResourceStoppedEvent to trigger subscriber cleanup and keep _environments dictionary in sync + var resourceEvent = new ResourceEvent(resource, resource.Name, new CustomResourceSnapshot + { + ResourceType = "temporal-local", + CreationTimeStamp = DateTime.UtcNow, + State = KnownResourceStates.Exited, + Properties = [] + }); + var stoppedEvent = new ResourceStoppedEvent(resource, context.ServiceProvider, resourceEvent); + await eventing.PublishAsync(stoppedEvent, context.CancellationToken); + + await notifications.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Exited + }); + + return CommandResults.Success(); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Error shutting down Temporal test server '{ResourceName}'", resource.Name); + return CommandResults.Failure(ex.Message); + } + }, + commandOptions: new CommandOptions + { + IconName = "Stop", + IconVariant = IconVariant.Filled, + IsHighlighted = true, + UpdateState = context => + { + var state = context.ResourceSnapshot.State?.Text; + if (IsStarting(state) || HasNoState(state)) + { + return ResourceCommandState.Disabled; + } + else if (IsRunning(state)) + { + return ResourceCommandState.Enabled; + } + else + { + return ResourceCommandState.Hidden; + } + } + }); + + resourceBuilder.WithCommand( + name: KnownResourceCommands.StartCommand, + displayName: "Start", + executeCommand: async context => + { + var notifications = context.ServiceProvider + .GetRequiredService(); + var resourceLogger = context.ServiceProvider + .GetRequiredService() + .GetLogger(resource); + var eventing = context.ServiceProvider + .GetRequiredService(); + + await notifications.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Starting + }); + + try + { + resourceLogger.LogInformation("Starting Temporal test server for resource '{ResourceName}'...", resource.Name); + var env = await WorkflowEnvironment.StartLocalAsync(resource.Options); + resource.WorkflowEnvironment = env; + + var targetHost = env.Client.Connection.Options.TargetHost ?? "unknown"; + + resourceLogger.LogInformation( + "Temporal test server started successfully. Target: {TargetHost}, Namespace: {Namespace}", + targetHost, + resource.Options.Namespace); + + await notifications.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Running, + Properties = + [ + new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, + "Temporalio.Testing.WorkflowEnvironment"), + new ResourcePropertySnapshot("temporal.target-host", targetHost), + new ResourcePropertySnapshot("temporal.namespace", resource.Options.Namespace) + ] + }); + + // Publish ResourceReadyEvent to signal that the resource is ready + await eventing.PublishAsync(new ResourceReadyEvent(resource, context.ServiceProvider), context.CancellationToken); + + return CommandResults.Success(); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Failed to start Temporal test server for resource '{ResourceName}'", resource.Name); + await notifications.PublishUpdateAsync(resource, s => s with + { + State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error) + }); + return CommandResults.Failure(ex.Message); + } + }, + commandOptions: new CommandOptions + { + IconName = "Play", + IconVariant = IconVariant.Filled, + IsHighlighted = true, + UpdateState = context => + { + var state = context.ResourceSnapshot.State?.Text; + if (IsStarting(state) || IsRuntimeUnhealthy(state) || HasNoState(state)) + { + return ResourceCommandState.Disabled; + } + + if (IsStopped(state) || IsWaiting(state)) + { + return ResourceCommandState.Enabled; + } + + return ResourceCommandState.Hidden; + } + }); + + return resourceBuilder; + + static bool IsStopped(string? state) => KnownResourceStates.TerminalStates.Contains(state) || + state == KnownResourceStates.NotStarted || state == "Unknown"; + + static bool IsRunning(string? state) => state == KnownResourceStates.Running; + static bool IsStarting(string? state) => state == KnownResourceStates.Starting; + static bool IsWaiting(string? state) => state == KnownResourceStates.Waiting; + static bool IsRuntimeUnhealthy(string? state) => state == KnownResourceStates.RuntimeUnhealthy; + static bool HasNoState(string? state) => string.IsNullOrEmpty(state); + } + + public static IResourceBuilder WithReference( + this IResourceBuilder builder, IResourceBuilder source) + where TDestination : IResourceWithEnvironment + { + return builder + .WithEnvironment(ctx => + { + ctx.EnvironmentVariables["TEMPORAL_ADDRESS"] = + $"localhost:{source.Resource.Options.Port}"; + + ctx.EnvironmentVariables["TEMPORAL_UI_ADDRESS"] = + $"http://localhost:{source.Resource.Options.UIPort}"; + + if (!string.IsNullOrEmpty(source.Resource.Options.CodecAuth)) + { + ctx.EnvironmentVariables["TEMPORAL_CODEC_AUTH"] = source.Resource.Options.CodecAuth; + } + + if (!string.IsNullOrEmpty(source.Resource.Options.CodecEndpoint)) + { + ctx.EnvironmentVariables["TEMPORAL_CODEC_ENDPOINT"] = source.Resource.Options.CodecEndpoint; + } + }); + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs new file mode 100644 index 0000000..517c48b --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs @@ -0,0 +1,148 @@ +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalLocalResourceSubscriber : IDistributedApplicationEventingSubscriber +{ + private readonly Dictionary environments = []; + + public Task SubscribeAsync(IDistributedApplicationEventing eventing, + DistributedApplicationExecutionContext executionContext, + CancellationToken cancellationToken) + { + // Don't start server in publish mode + if (executionContext.IsPublishMode) + { + return Task.CompletedTask; + } + + // Subscribe to InitializeResourceEvent for each TemporalLocalResource + eventing.Subscribe(OnInitializeAsync); + return Task.CompletedTask; + } + + private async Task OnInitializeAsync(InitializeResourceEvent @event, CancellationToken cancellationToken = default) + { + if (@event.Resource is not TemporalLocalResource resource) + { + return; + } + + var resourceLoggerService = @event.Services.GetRequiredService(); + + // Subscribe to ResourceStoppedEvent for this specific resource to handle cleanup + @event.Eventing.Subscribe(resource, + (stopEvent, _) => OnResourceStoppedAsync(stopEvent, resourceLoggerService, resource)); + + await StartTemporalTestServerAsync(resource, @event.Eventing, + @event.Notifications, resourceLoggerService, @event.Services, cancellationToken); + } + + private async Task StartTemporalTestServerAsync(TemporalLocalResource resource, + IDistributedApplicationEventing eventing, + ResourceNotificationService resourceNotificationService, + ResourceLoggerService resourceLoggerService, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var resourceLogger = resourceLoggerService.GetLogger(resource); + + try + { + // Publish starting state + await resourceNotificationService.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Starting, KnownResourceStateStyles.Info), + StartTimeStamp = DateTime.UtcNow + }); + + resourceLogger.LogInformation("Starting Temporal test server for resource '{ResourceName}'...", + resource.Name); + + var env = await WorkflowEnvironment.StartLocalAsync(resource.Options); + + // Store the environment for later shutdown (before publishing events) + environments[resource.Name] = env; + + // Set the environment on the resource so it can be accessed + resource.WorkflowEnvironment = env; + + var targetHost = env.Client.Connection.Options.TargetHost ?? "unknown"; + var namespaces = string.Join(", ", resource.Options.AdditionalNamespaces); + + resourceLogger.LogInformation( + "Temporal test server started successfully. Target: {TargetHost}, Namespaces: {Namespaces}", + targetHost, + namespaces); + + // Publish running state with properties + await resourceNotificationService.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, + "Temporalio.Testing.WorkflowEnvironment"), + new ResourcePropertySnapshot("temporal.target-host", targetHost), + new ResourcePropertySnapshot("temporal.namespace", resource.Options.Namespace), + new ResourcePropertySnapshot("temporal.namespaces", namespaces) + ] + }); + + // Now publish events (after environment is properly stored and assigned) + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(resource, serviceProvider); + await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); + + // Publish ResourceReadyEvent to signal that the resource is ready + await eventing.PublishAsync(new ResourceReadyEvent(resource, serviceProvider), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Failed to start Temporal test server for resource '{ResourceName}'", + resource.Name); + + // Publish failed state + await resourceNotificationService.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error) + }); + + throw; + } + } + + private async Task OnResourceStoppedAsync(ResourceStoppedEvent @event, + ResourceLoggerService resourceLoggerService, TemporalLocalResource resource) + { + var resourceName = @event.Resource.Name; + var resourceLogger = resourceLoggerService.GetLogger(resource); + + // Get environment from resource property first, fallback to tracking dictionary + var env = resource.WorkflowEnvironment ?? environments.GetValueOrDefault(resourceName); + + if (env != null) + { + try + { + resourceLogger.LogInformation("Shutting down Temporal test server '{ResourceName}'...", resourceName); + await env.ShutdownAsync(); + resource.WorkflowEnvironment = null; + resourceLogger.LogInformation("Temporal test server '{ResourceName}' shut down successfully.", + resourceName); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Error shutting down Temporal test server '{ResourceName}'", resourceName); + } + finally + { + environments.Remove(resourceName); + } + } + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs new file mode 100644 index 0000000..1faa8eb --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs @@ -0,0 +1,18 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +public static class TemporalResourceConstants +{ + public const string ServiceEndpointName = "grpc"; + public const int DefaultServiceEndpointPort = 7233; + + public const string UIEndpointName = "ui"; + public const int DefaultUIEndpointPort = 8233; + + public const string MetricsEndpointName = "metrics"; + public const int DefaultMetricsEndpointPort = 9233; + + public const string TemporalImage = "temporalio/temporal"; + public const string DefaultTag = "latest"; + + public const string DefaultWorkingDirectory = "./"; +} \ No newline at end of file diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs new file mode 100644 index 0000000..96e8247 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs @@ -0,0 +1,69 @@ +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +public class TemporalResourceOptions : WorkflowEnvironmentStartLocalOptions +{ + private List additionalNamespaces; + + public TemporalResourceOptions() + { + // Set defaults that differ from base class + UIPort = TemporalResourceConstants.DefaultUIEndpointPort; + UI = true; + TargetHost = $"0.0.0.0:{TemporalResourceConstants.DefaultServiceEndpointPort}"; + + // Initialize AdditionalNamespaces with the primary namespace + additionalNamespaces = [Namespace]; + } + + public new List AdditionalNamespaces + { + get => additionalNamespaces.Count > 0 ? additionalNamespaces : [Namespace]; + set => additionalNamespaces = value ?? []; + } + + public int Port + { + get + { + if (string.IsNullOrEmpty(TargetHost)) + throw new InvalidOperationException("TargetHost must be set before accessing Port."); + + var parts = TargetHost.Split(':'); + if (parts.Length == 2 && int.TryParse(parts[1], out var port)) + return port; + + throw new InvalidOperationException($"TargetHost '{TargetHost}' is not in the expected 'ip:port' format."); + } + } + + /// + /// Gets the IP address to bind to, parsed from TargetHost. + /// Maps to --ip CLI argument and DevServerOptions.Ip concept. + /// + public string Ip + { + get + { + if (string.IsNullOrEmpty(TargetHost)) + throw new InvalidOperationException("TargetHost must be set before accessing Ip."); + + var parts = TargetHost.Split(':'); + if (parts.Length > 0 && !string.IsNullOrEmpty(parts[0])) + return parts[0]; + + return "0.0.0.0"; + } + } + + public int MetricsPort { get; set; } = TemporalResourceConstants.DefaultMetricsEndpointPort; + + public bool IsHeadless => !UI; + + public List DynamicConfigValues { get; } = []; + + public string? CodecAuth { get; set; } + + public string? CodecEndpoint { get; set; } +} \ No newline at end of file diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs new file mode 100644 index 0000000..f14288c --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs @@ -0,0 +1,15 @@ +using Temporal.Extensions.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +var temporal = builder.AddTemporalLocalTestServer(); + +builder.AddProject("sample-temporal-worker") + .WaitFor(temporal) + .WithReference(temporal); + +builder.AddProject("sample-temporal-client") + .WaitFor(temporal) + .WithReference(temporal); + +builder.Build().Run(); \ No newline at end of file diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json new file mode 100644 index 0000000..84c4b13 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17197;http://localhost:15132", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21202", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23199", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22048" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15132", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19084", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18042", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20034" + } + } + } +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj new file mode 100644 index 0000000..ffcfad3 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + 4a9378ba-a2ca-4863-9c5c-98903998bef0 + + + + + + + + + + + + + + diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs b/src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs new file mode 100644 index 0000000..d1be5ef --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs @@ -0,0 +1,24 @@ +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using TemporalioSamples.SampleWorkflow; + +try +{ + var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); + Console.WriteLine("\nAttempting to connect client to temporal server..."); + + var client = await TemporalClient.ConnectAsync(connectOptions); + Console.WriteLine("✅ Client connected successfully!"); + + await client.StartWorkflowAsync( + (SimpleWorkflow wf) => wf.RunAsync(), + new(id: "simple-workflow-id", taskQueue: "simple-task-queue")); + + Console.WriteLine("✅ Workflow invoked successfully!"); +} +#pragma warning disable CA1031 +catch (Exception ex) +#pragma warning restore CA1031 +{ + Console.WriteLine($"❌ Failed to start workflow: {ex.Message}"); +} \ No newline at end of file diff --git a/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj b/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj new file mode 100644 index 0000000..afeb7e5 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs new file mode 100644 index 0000000..7f309c1 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using Temporalio.Common.EnvConfig; +using Temporalio.Extensions.Hosting; +using TemporalioSamples.SampleWorkflow; + +var builder = Host.CreateApplicationBuilder(args); + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); + +builder.Services.AddHostedTemporalWorker( + clientTargetHost: connectOptions.TargetHost ?? "localhost:7233", + clientNamespace: connectOptions.Namespace, + taskQueue: "simple-task-queue") + .AddScopedActivities() + .AddWorkflow(); + +var host = builder.Build(); +await host.RunAsync(); \ No newline at end of file diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj b/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj new file mode 100644 index 0000000..534a65a --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + SampleApp + + + + + + + + + + + + + diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs new file mode 100644 index 0000000..8aff8e0 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Activities; + +namespace TemporalioSamples.SampleWorkflow; + +public class SimpleActivities +{ + [Activity] + public async Task DoSomethingAsync() + { + ActivityExecutionContext.Current.Logger.LogInformation("Doing something async!"); + await Task.Delay(TimeSpan.FromSeconds(2)); + ActivityExecutionContext.Current.Logger.LogInformation("Done something async!"); + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs new file mode 100644 index 0000000..70e4e87 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Workflows; + +namespace TemporalioSamples.SampleWorkflow; + +[Workflow] +public class SimpleWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + Workflow.Logger.LogInformation("Starting workflow..."); + await Workflow.ExecuteActivityAsync((SimpleActivities a) => a.DoSomethingAsync(), new() { StartToCloseTimeout = TimeSpan.FromMinutes(4) }); + Workflow.Logger.LogInformation("Workflow completed!"); + } +} \ No newline at end of file diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj new file mode 100644 index 0000000..e9a6dfe --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + enable + enable + + From d89d4bcfd9816d79e23535c96db4459f016d45d4 Mon Sep 17 00:00:00 2001 From: Cecil Phillip Date: Mon, 23 Feb 2026 11:15:05 -0500 Subject: [PATCH 2/3] Update src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs Co-authored-by: Chad Retz --- .../TemporalioSamples.SampleAppHost/AppHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs index f14288c..c1ad865 100644 --- a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs @@ -2,7 +2,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var temporal = builder.AddTemporalLocalTestServer(); +var temporal = builder.AddTemporalLocalDevServer(); builder.AddProject("sample-temporal-worker") .WaitFor(temporal) From ff59b4f583fbf81dda4afec99fb812eac086029e Mon Sep 17 00:00:00 2001 From: Cecil Phillip Date: Mon, 23 Feb 2026 11:19:19 -0500 Subject: [PATCH 3/3] Rename Temporal local test server --- src/AspireIntegrations/README.md | 24 +++++++++++++++---- .../TemporalLocalResourceExtensions.cs | 2 +- .../AppHost.cs | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/AspireIntegrations/README.md b/src/AspireIntegrations/README.md index 2fa54ec..35128c5 100644 --- a/src/AspireIntegrations/README.md +++ b/src/AspireIntegrations/README.md @@ -27,9 +27,25 @@ This project provides custom Aspire resource definitions that enable developers ### For CLI-based Setup - **Temporal CLI** - Install via [Temporal CLI documentation](https://docs.temporal.io/cli/install) -## Quick Start +## Running the Project -### Local Server Setup +Using the Aspire CLI + +1. **Navigate to the AppHost project directory:** + ```bash + cd src/AspireIntegrations/ + ``` + +2. **Run the project using the Aspire CLI:** + ```bash + aspire run + ``` + +> You can also run the project directly with `dotnet run` from the AppHost directory, or use your IDE's run configuration. + +## Setup Options + +### Local Server The local server setup uses a Temporal environment for fast testing without external dependencies. It will download and run the necessary Temporal server binaries. @@ -90,7 +106,7 @@ var temporal = builder.AddTemporalLocalTestServer(configure: options => --- -### Container-based Setup +### Container-based Deploy Temporal using the CLI Docker container. @@ -141,7 +157,7 @@ var temporal = builder.AddTemporalDevContainer(configure: options => ``` --- -### CLI-based Setup +### CLI-based Use the Temporal CLI server for environments without Docker. diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs index 538953e..74735fc 100644 --- a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs @@ -9,7 +9,7 @@ namespace Temporal.Extensions.Aspire.Hosting; public static class TemporalLocalResourceExtensions { - public static IResourceBuilder AddTemporalLocalTestServer( + public static IResourceBuilder AddTemporalLocalDevServer( this IDistributedApplicationBuilder builder, string name = "temporal-local", Action? configure = null) diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs index f14288c..c1ad865 100644 --- a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs @@ -2,7 +2,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var temporal = builder.AddTemporalLocalTestServer(); +var temporal = builder.AddTemporalLocalDevServer(); builder.AddProject("sample-temporal-worker") .WaitFor(temporal)