diff --git a/Directory.Packages.props b/Directory.Packages.props index 1b0335c..6696dbf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,9 +8,9 @@ - - - + + + @@ -21,6 +21,7 @@ + diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index 4f16013..c8c62db 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -107,6 +107,17 @@ 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}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StandaloneActivity", "StandaloneActivity", "{EAB0C45A-7620-D2D2-2901-5E7FCBFFDA77}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.StandaloneActivity", "src\StandaloneActivity\TemporalioSamples.StandaloneActivity.csproj", "{240517A1-13B5-4A67-8519-BFCF2C4591B9}" @@ -639,6 +650,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 {240517A1-13B5-4A67-8519-BFCF2C4591B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {240517A1-13B5-4A67-8519-BFCF2C4591B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {240517A1-13B5-4A67-8519-BFCF2C4591B9}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -716,6 +787,12 @@ 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} {EAB0C45A-7620-D2D2-2901-5E7FCBFFDA77} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {240517A1-13B5-4A67-8519-BFCF2C4591B9} = {EAB0C45A-7620-D2D2-2901-5E7FCBFFDA77} {5D493692-53AB-4FAA-BA4D-33B1E54E9A48} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} diff --git a/src/AspireIntegrations/README.md b/src/AspireIntegrations/README.md new file mode 100644 index 0000000..4e73c91 --- /dev/null +++ b/src/AspireIntegrations/README.md @@ -0,0 +1,277 @@ +# 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 SDK](https://dot.net)** 8.0 or later +- **[Aspire tooling](https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling)** 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) + +## Running the Project + +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. + +**AppHost.cs:** +```csharp +using Temporal.Extensions.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add Temporal local server +var temporal = builder.AddTemporalLocalDevServer(); + +// Add a worker project that depends on Temporal +builder.AddProject("worker") + .WaitFor(temporal) + .WithReference(temporal); + +builder.Build().Run(); +``` + +**Advanced Configuration:** +```csharp +var temporal = builder.AddTemporalLocalDevServer(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 + +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 + +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(); +``` + +**Advanced Configuration:** +```csharp +var temporal = builder.AddTemporalCliServer(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"]; + + // 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" + ]; +}); +``` + +**Requirements:** +- Temporal CLI must be installed and available in PATH +- Run `temporal --version` to verify installation +--- + +### Connection Strings + +Dependent projects receive the following environment variables automatically: + +| Variable | Description | +|----------|-------------| +| `TEMPORAL_ADDRESS` | The gRPC server address (e.g., `localhost:7233` or container name:port) | +| `TEMPORAL_UI_ADDRESS` | The Web UI address (e.g., `http://localhost:8233`) | +| `TEMPORAL_NAMESPACE` | The namespace to use (matches the resource's configured namespace) | +| `TEMPORAL_CODEC_AUTH` | Optional codec authentication token (if configured) | +| `TEMPORAL_CODEC_ENDPOINT` | Optional codec server endpoint (if configured) | + +**Example usage:** +```csharp +var temporalAddress = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS"); +var temporalUiAddress = Environment.GetEnvironmentVariable("TEMPORAL_UI_ADDRESS"); +var temporalNamespace = Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default"; +``` +--- + +## 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/Properties/AssemblyInfo.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..11d991e --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TemporalioSamples.Tests")] 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..6f5e6dd --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj @@ -0,0 +1,13 @@ + + + + enable + enable + $(NoWarn);SA1010;SA1116;SA1413;SA1117;CA1031;SA1503;CS0109;CA1002;CA2227;CA1305 + + + + + + + diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalArgsBuilder.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalArgsBuilder.cs new file mode 100644 index 0000000..ce7f157 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalArgsBuilder.cs @@ -0,0 +1,62 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Builds command-line arguments for Temporal dev server startup. +/// +internal static class TemporalArgsBuilder +{ + /// + /// Builds the CLI arguments for a Temporal dev server. + /// When is true the IP is always "0.0.0.0" and the port + /// is always (container mode). + /// When false, IP and port are taken from and --ui-port is also emitted (CLI mode). + /// + /// The resource options containing host, ports, and namespace configuration. + /// If true, uses fixed container defaults; if false, uses options values and emits --ui-port. + /// An array of CLI arguments for the temporal server start-dev command. + internal static string[] BuildArgs(TemporalResourceOptions options, bool fixedIpAndPort = false) + { + var args = new List { "server", "start-dev" }; + + if (fixedIpAndPort) + { + args.AddRange(["--ip", "0.0.0.0"]); + args.AddRange(["--port", $"{TemporalResourceConstants.DefaultServiceEndpointPort}"]); + } + else + { + 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]); + + 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(); + } +} diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliLocator.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliLocator.cs new file mode 100644 index 0000000..69f9610 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliLocator.cs @@ -0,0 +1,42 @@ +using System.Runtime.InteropServices; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Utility for locating and validating the Temporal CLI executable on the system PATH. +/// +internal static class TemporalCliLocator +{ + /// + /// Throws when the temporal CLI executable + /// cannot be found on the system PATH. + /// + /// + /// Optional override for the availability check. When null (default) the real PATH is + /// inspected. Pass a custom delegate in tests to simulate presence or absence of the CLI + /// without depending on the test machine's PATH. + /// + internal static void EnsureAvailable(Func? isAvailable = null) + { + if (!(isAvailable ?? IsOnPath)()) + { + throw new InvalidOperationException( + "The 'temporal' CLI executable was not found on PATH. " + + "Install it from https://docs.temporal.io/cli and ensure " + + "'temporal' is accessible on your PATH before using AddTemporalCliServer."); + } + } + + private static bool IsOnPath() + { + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "temporal.exe" + : "temporal"; + + return pathEnv + .Split(Path.PathSeparator) + .Select(dir => Path.Combine(dir, executableName)) + .Any(File.Exists); + } +} 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..ee89906 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs @@ -0,0 +1,23 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Represents a Temporal server running via the CLI executable. +/// +public class TemporalCliServerResource(string name, string workingDirectory = "./") + : ExecutableResource(name, "temporal", workingDirectory), IResourceWithConnectionString, + IResourceWithServiceDiscovery +{ + private EndpointReference? primaryEndpoint; + + /// Gets the primary gRPC service endpoint. + public EndpointReference PrimaryEndpoint => + primaryEndpoint ??= new(this, TemporalResourceConstants.ServiceEndpointName); + + /// Gets the connection string expression for dependent services. + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + /// Gets or sets the resource configuration options. + public TemporalResourceOptions Options { get; set; } = new(); +} 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..1140a79 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Extension methods for registering Temporal CLI server resources in Aspire. +/// +public static class TemporalCliServerResourceExtensions +{ + /// + /// Adds a Temporal CLI server resource to the distributed application. + /// + /// The distributed application builder. + /// The resource name. Default is "temporal-cli-server". + /// Optional action to configure the resource options. + /// A builder for the Temporal CLI server resource. + public static IResourceBuilder AddTemporalCliServer( + this IDistributedApplicationBuilder builder, + string name = "temporal-cli-server", + Action? configure = null) + { + return builder.AddTemporalCliServer(name, configure, isTemporalCliAvailable: null); + } + + /// + /// Adds a reference from a dependent service to a Temporal CLI server resource, + /// automatically injecting connection environment variables. + /// + /// The type of the destination resource. + /// The resource builder for the dependent service. + /// The Temporal CLI server resource builder. + /// The updated resource builder. + public static IResourceBuilder WithReference( + this IResourceBuilder builder, IResourceBuilder source) + where TDestination : IResourceWithEnvironment + { + return builder + .WithReference((IResourceBuilder)source) + .WithEnvironment(ctx => + { + TemporalEnvironmentHelper.AddEnvironmentVariables( + ctx, + source.Resource.Options, + source.Resource.ConnectionStringExpression, + source.GetEndpoint(TemporalResourceConstants.UIEndpointName)); + }); + } + + // Internal overload that accepts an injectable CLI availability check. + // Used by tests to simulate an absent 'temporal' binary without modifying PATH. + internal static IResourceBuilder AddTemporalCliServer( + this IDistributedApplicationBuilder builder, + string name, + Action? configure, + Func? isTemporalCliAvailable) + { + TemporalCliLocator.EnsureAvailable(isTemporalCliAvailable); + var resource = new TemporalCliServerResource(name); + configure?.Invoke(resource.Options); + + var clientAccessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor( + builder, resource, resource.Options.Namespace); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .AddTemporalHealthCheck(clientAccessor, healthCheckKey); + + return builder.AddResource(resource) + .WithArgs(TemporalArgsBuilder.BuildArgs(resource.Options)) + .ExcludeFromManifest() + .WithEndpoint( + targetPort: resource.Options.Port, + port: resource.Options.Port, + isProxied: false, + name: TemporalResourceConstants.ServiceEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.UIPort, + port: resource.Options.UIPort, + isProxied: false, + name: TemporalResourceConstants.UIEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.MetricsPort, + port: resource.Options.MetricsPort, + isProxied: false, + name: TemporalResourceConstants.MetricsEndpointName) + .WithHealthCheck(healthCheckKey) + .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url => + { + url.DisplayText = "Dashboard"; + }); + } +} 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..573ae62 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs @@ -0,0 +1,10 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Configuration options specific to Temporal Docker container deployments. +/// +public class TemporalContainerOptions : TemporalResourceOptions +{ + /// Gets or sets the Docker image tag. Default is "latest". + public string? ImageTag { get; set; } = TemporalResourceConstants.DefaultTag; +} 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..520faac --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs @@ -0,0 +1,20 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Represents a Temporal server running as a Docker container. +/// +public class TemporalContainerResource(string name) : ContainerResource(name), IResourceWithConnectionString, IResourceWithServiceDiscovery +{ + private EndpointReference? primaryEndpoint; + + /// Gets the primary gRPC service endpoint. + public EndpointReference PrimaryEndpoint => primaryEndpoint ??= new(this, TemporalResourceConstants.ServiceEndpointName); + + /// Gets the connection string expression for dependent services. + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + /// Gets or sets the container configuration options. + public TemporalContainerOptions Options { get; set; } = new(); +} diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResourceExtensions.cs new file mode 100644 index 0000000..ba2b574 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResourceExtensions.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Extension methods for registering Temporal container resources in Aspire. +/// +public static class TemporalContainerResourceExtensions +{ + /// + /// Adds a Temporal Docker container resource to the distributed application. + /// + /// The distributed application builder. + /// The resource name. Default is "temporal-container". + /// Optional action to configure the container options. + /// A builder for the Temporal container resource. + public static IResourceBuilder AddTemporalDevContainer( + this IDistributedApplicationBuilder builder, + string name = "temporal-container", + Action? configure = null) + { + var resource = new TemporalContainerResource(name); + configure?.Invoke(resource.Options); + + var clientAccessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor( + builder, resource, resource.Options.Namespace); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .AddTemporalHealthCheck(clientAccessor, healthCheckKey); + + return builder.AddResource(resource) + .WithImage(TemporalResourceConstants.TemporalImage, + resource.Options.ImageTag ?? TemporalResourceConstants.DefaultTag) + .WithImageRegistry("docker.io") + .WithArgs(TemporalArgsBuilder.BuildArgs(resource.Options, fixedIpAndPort: true)) + .ExcludeFromManifest() + .WithEndpoint( + targetPort: TemporalResourceConstants.DefaultServiceEndpointPort, + port: resource.Options.Port, + isProxied: false, + name: TemporalResourceConstants.ServiceEndpointName) + .WithHttpEndpoint( + targetPort: TemporalResourceConstants.DefaultUIEndpointPort, + port: resource.Options.UIPort, + isProxied: false, + name: TemporalResourceConstants.UIEndpointName) + .WithHttpEndpoint( + targetPort: TemporalResourceConstants.DefaultMetricsEndpointPort, + port: resource.Options.MetricsPort, + isProxied: false, + name: TemporalResourceConstants.MetricsEndpointName) + .WithHealthCheck(healthCheckKey) + .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url => + { + url.DisplayText = "Dashboard"; + }); + } + + /// + /// Adds a reference from a dependent service to a Temporal container resource, + /// automatically injecting connection environment variables. + /// + /// The type of the destination resource. + /// The resource builder for the dependent service. + /// The Temporal container resource builder. + /// The updated resource builder. + public static IResourceBuilder WithReference( + this IResourceBuilder builder, IResourceBuilder source) + where TDestination : IResourceWithEnvironment + { + return builder + .WithReference((IResourceBuilder)source) + .WithEnvironment(ctx => + { + TemporalEnvironmentHelper.AddEnvironmentVariables( + ctx, + source.Resource.Options, + source.Resource.ConnectionStringExpression, + source.GetEndpoint(TemporalResourceConstants.UIEndpointName)); + }); + } +} diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalEnvironmentHelper.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalEnvironmentHelper.cs new file mode 100644 index 0000000..67c6e66 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalEnvironmentHelper.cs @@ -0,0 +1,33 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Helper for injecting Temporal connection details as environment variables into dependent services. +/// +internal static class TemporalEnvironmentHelper +{ + /// + /// Adds Temporal connection and configuration environment variables to a dependent service. + /// This helper only maps the resource connection details and namespace into dependent services. + /// It intentionally does not duplicate all resource-specific Temporal environment variables. + /// + /// The environment callback context for the dependent service. + /// The Temporal resource options containing namespace and codec configuration. + /// The gRPC server address expression. + /// The Web UI address expression. + internal static void AddEnvironmentVariables( + EnvironmentCallbackContext ctx, + TemporalResourceOptions options, + object temporalAddress, + object temporalUiAddress) + { + ctx.EnvironmentVariables["TEMPORAL_ADDRESS"] = temporalAddress; + ctx.EnvironmentVariables["TEMPORAL_UI_ADDRESS"] = temporalUiAddress; + ctx.EnvironmentVariables["TEMPORAL_NAMESPACE"] = options.Namespace; + + if (!string.IsNullOrEmpty(options.CodecAuth)) + ctx.EnvironmentVariables["TEMPORAL_CODEC_AUTH"] = options.CodecAuth; + + if (!string.IsNullOrEmpty(options.CodecEndpoint)) + ctx.EnvironmentVariables["TEMPORAL_CODEC_ENDPOINT"] = options.CodecEndpoint; + } +} 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..0c517e1 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Temporalio.Api.WorkflowService.V1; +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Health check implementation for Temporal servers. +/// +public class TemporalHealthCheck(Func> clientAccessor) : IHealthCheck +{ + /// + /// Checks the health of the Temporal server by calling GetSystemInfoAsync. + /// + /// The health check context providing access to registered services. + /// The cancellation token for this operation. + /// A HealthCheckResult indicating whether the server is reachable and healthy. + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var client = await clientAccessor(cancellationToken); + if (client is null) + return HealthCheckResult.Unhealthy("Temporal client not yet initialized"); + + try + { + await client.WorkflowService.GetSystemInfoAsync( + new GetSystemInfoRequest(), + new RpcOptions { CancellationToken = cancellationToken }); + return HealthCheckResult.Healthy(); + } + catch (Exception e) + { + return HealthCheckResult.Unhealthy("Unable to reach Temporal server", e); + } + } +} 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..6a25ee4 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Extension methods for adding Temporal health checks to Aspire applications. +/// +public static class TemporalHealthCheckBuilderExtensions +{ + /// + /// Adds a Temporal health check to the health checks builder. + /// + /// The health checks builder. + /// A function that provides access to a Temporal client. + /// The health check name. Default is "temporal". + /// Optional tags to associate with the health check. + /// Optional timeout for the health check. + /// The updated health checks builder. + public static IHealthChecksBuilder AddTemporalHealthCheck( + this IHealthChecksBuilder builder, + Func> clientAccessor, + string name = "temporal", + IEnumerable? tags = null, + TimeSpan? timeout = null) + { + return builder.Add(new HealthCheckRegistration( + name, + _ => new TemporalHealthCheck(clientAccessor), + HealthStatus.Unhealthy, + tags, + timeout)); + } +} diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckHelper.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckHelper.cs new file mode 100644 index 0000000..b6aea70 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckHelper.cs @@ -0,0 +1,85 @@ +using Temporalio.Client; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Helper for registering cached Temporal client accessors for health checks. +/// +internal static class TemporalHealthCheckHelper +{ + /// + /// Subscribes to for a CLI or container resource, + /// creates a once the endpoint is available, and returns an accessor + /// that the health check can call on every probe. + /// The cached client is replaced on each subsequent event so restarts are covered. + /// + /// The distributed application builder for subscribing to events. + /// The Temporal resource being monitored for connection string availability. + /// The Temporal namespace for client connections. + /// A function that accepts a cancellation token and returns the cached ITemporalClient or null if not yet connected. + internal static Func> RegisterCachedClientAccessor( + IDistributedApplicationBuilder builder, + IResource resource, + string @namespace) + { + ITemporalClient? cachedClient = null; + string? hostPort = null; + + async Task EnsureClientConnectedAsync(CancellationToken cancellationToken) + { + if (cachedClient is not null) + return cachedClient; + + if (string.IsNullOrEmpty(hostPort)) + return null; + + try + { + cachedClient = await TemporalClient.ConnectAsync(new TemporalClientConnectOptions + { + Namespace = @namespace, + TargetHost = hostPort + }); + return cachedClient; + } + catch (InvalidOperationException) + { + cachedClient = null; + return null; + } + } + + builder.Eventing.Subscribe(resource, async (@event, _) => + { + try + { + if (!@event.Resource.TryGetEndpoints(out var endpoints)) + return; + + var serviceEndpoint = endpoints.Single(e => e.Name == TemporalResourceConstants.ServiceEndpointName); + hostPort = $"{serviceEndpoint.TargetHost}:{serviceEndpoint.Port}"; + cachedClient = null; + + // The endpoint can be published before Temporal is accepting connections. + // Retry a few times but never throw from this callback. + const int maxAttempts = 30; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + var client = await EnsureClientConnectedAsync(CancellationToken.None); + if (client is not null) + break; + + if (attempt < maxAttempts) + await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None); + } + } + catch + { + // Resource startup must not fail because client warm-up failed. + cachedClient = null; + } + }); + + return EnsureClientConnectedAsync; + } +} 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..084351f --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs @@ -0,0 +1,16 @@ +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Represents a Temporal server running locally via . +/// +public class TemporalLocalResource(string name) + : Resource(name), IResourceWithServiceDiscovery +{ + /// Gets or sets the underlying workflow environment instance. + public WorkflowEnvironment? WorkflowEnvironment { get; set; } + + /// Gets or sets the resource configuration options. + public TemporalResourceOptions Options { get; set; } = new(); +} 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..b6f4bb5 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs @@ -0,0 +1,240 @@ +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Extension methods for registering local Temporal server resources in Aspire. +/// +public static class TemporalLocalResourceExtensions +{ + /// + /// Adds a local Temporal server resource (via WorkflowEnvironment) to the distributed application. + /// + /// The distributed application builder. + /// The resource name. Default is "temporal-local". + /// Optional action to configure the resource options. + /// A builder for the local Temporal server resource. + public static IResourceBuilder AddTemporalLocalDevServer( + 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(_ => Task.FromResult(resource.WorkflowEnvironment?.Client), 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, + port: resource.Options.UIPort, + isProxied: false, + name: TemporalResourceConstants.UIEndpointName) + .WithHttpEndpoint( + targetPort: resource.Options.MetricsPort, + port: 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 + { + // Publish ResourceStoppedEvent to trigger subscriber cleanup and keep _environments dictionary in sync + // The subscriber's OnResourceStoppedAsync performs the actual ShutdownAsync exactly once. + 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 => + { + TemporalEnvironmentHelper.AddEnvironmentVariables( + ctx, + source.Resource.Options, + $"localhost:{source.Resource.Options.Port}", + $"http://localhost:{source.Resource.Options.UIPort}"); + }); + } +} 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..251a340 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs @@ -0,0 +1,168 @@ +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Event subscriber for managing local Temporal server lifecycle. +/// +public class TemporalLocalResourceSubscriber : IDistributedApplicationEventingSubscriber +{ + private readonly Dictionary environments = []; + + /// + /// Subscribes to resource events for initializing and managing local Temporal servers. + /// + /// The distributed application eventing service. + /// The execution context containing application state and mode information. + /// The cancellation token for this operation. + /// A completed task once event subscriptions are registered. + 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; + } + + /// + /// Handles initialization of local Temporal resources. + /// + 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); + } + + /// + /// Starts the local Temporal test server and publishes connection events. + /// + 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; + } + } + + /// + /// Handles cleanup when a local Temporal resource is stopped. + /// + 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); + throw; + } + finally + { + environments.Remove(resourceName); + } + } + } +} 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..83dcb54 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs @@ -0,0 +1,34 @@ +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Constants used for Temporal resource configuration and endpoints. +/// +public static class TemporalResourceConstants +{ + /// The name of the gRPC service endpoint. + public const string ServiceEndpointName = "grpc"; + + /// The default port for the gRPC service endpoint. + public const int DefaultServiceEndpointPort = 7233; + + /// The name of the Web UI endpoint. + public const string UIEndpointName = "ui"; + + /// The default port for the Web UI endpoint. + public const int DefaultUIEndpointPort = 8233; + + /// The name of the metrics endpoint. + public const string MetricsEndpointName = "metrics"; + + /// The default port for the metrics endpoint. + public const int DefaultMetricsEndpointPort = 9233; + + /// The Docker image name for Temporal. + public const string TemporalImage = "temporalio/temporal"; + + /// The default Docker image tag. + public const string DefaultTag = "latest"; + + /// The default working directory for Temporal CLI execution. + public const string DefaultWorkingDirectory = "./"; +} 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..7f9ccd4 --- /dev/null +++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs @@ -0,0 +1,97 @@ +using Temporalio.Testing; + +namespace Temporal.Extensions.Aspire.Hosting; + +/// +/// Configuration options for Temporal resources (local, container, and CLI-based). +/// Extends with Aspire-specific settings. +/// +public class TemporalResourceOptions : WorkflowEnvironmentStartLocalOptions +{ + private List extraNamespaces = []; + + public TemporalResourceOptions() + { + // Set defaults that differ from base class + UIPort = TemporalResourceConstants.DefaultUIEndpointPort; + UI = true; + TargetHost = $"0.0.0.0:{TemporalResourceConstants.DefaultServiceEndpointPort}"; + } + + /// + /// Gets or sets additional namespaces beyond the primary namespace. + /// Always includes the primary in the returned list. + /// + public new List AdditionalNamespaces + { + get + { + var result = new List { Namespace }; + foreach (var ns in extraNamespaces) + { + if (ns != Namespace && !result.Contains(ns)) + result.Add(ns); + } + return result; + } + + set + { + extraNamespaces = value == null + ? [] + : value.Where(ns => !string.IsNullOrEmpty(ns) && ns != Namespace).Distinct().ToList(); + } + } + + /// + /// Gets the gRPC port, parsed from . + /// + 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"; + } + } + + /// Gets or sets the metrics endpoint port. Default is 9233. + public int MetricsPort { get; set; } = TemporalResourceConstants.DefaultMetricsEndpointPort; + + /// Gets a value indicating whether the UI is disabled. + public bool IsHeadless => !UI; + + /// Gets or sets dynamic configuration values for the Temporal server. + public List DynamicConfigValues { get; set; } = []; + + /// Gets or sets the codec authentication token for encrypted payloads. + public string? CodecAuth { get; set; } + + /// Gets or sets the codec server endpoint for encrypted payloads. + public string? CodecEndpoint { get; set; } +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs new file mode 100644 index 0000000..75c4d7d --- /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.AddTemporalCliServer(); + +builder.AddProject("sample-temporal-worker") + .WaitFor(temporal) + .WithReference(temporal); + +builder.AddProject("sample-temporal-client") + .WaitFor(temporal) + .WithReference(temporal); + +builder.Build().Run(); 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..621576e --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj @@ -0,0 +1,16 @@ + + + + Exe + 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..219b12d --- /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}"); +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj b/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj new file mode 100644 index 0000000..96a796b --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj @@ -0,0 +1,13 @@ + + + + Exe + enable + enable + + + + + + + diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs new file mode 100644 index 0000000..c5c71b5 --- /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(); diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj b/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj new file mode 100644 index 0000000..9c84f21 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj @@ -0,0 +1,18 @@ + + + + Exe + 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..3e8f178 --- /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!"); + } +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs new file mode 100644 index 0000000..42fd060 --- /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!"); + } +} diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj new file mode 100644 index 0000000..9591b64 --- /dev/null +++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj @@ -0,0 +1,7 @@ + + + + enable + enable + + diff --git a/src/AspireIntegrations/aspire.config.json b/src/AspireIntegrations/aspire.config.json new file mode 100644 index 0000000..6b536b3 --- /dev/null +++ b/src/AspireIntegrations/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj" + } +} diff --git a/tests/AspireIntegrations/TemporalCliServerResourceExtensionsTests.cs b/tests/AspireIntegrations/TemporalCliServerResourceExtensionsTests.cs new file mode 100644 index 0000000..770aaff --- /dev/null +++ b/tests/AspireIntegrations/TemporalCliServerResourceExtensionsTests.cs @@ -0,0 +1,165 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Temporal.Extensions.Aspire.Hosting; +using Xunit; + +namespace TemporalioSamples.Tests.AspireIntegrations; + +public class TemporalCliServerResourceExtensionsTests +{ + // ----------------------------------------------------------------------- + // TemporalCliLocator — PATH guard that backs AddTemporalCliServer + // ----------------------------------------------------------------------- + [Fact] + public void EnsureAvailable_ThrowsInvalidOperationException_WhenCliNotFound() + { + // The isAvailable override lets tests simulate a machine without 'temporal' installed + // without manipulating the real PATH environment variable. + var ex = Assert.Throws( + () => TemporalCliLocator.EnsureAvailable(() => false)); + + Assert.Contains("temporal", ex.Message); + Assert.Contains("https://", ex.Message); + } + + [Fact] + public void EnsureAvailable_DoesNotThrow_WhenCliIsFound() + { + // Should complete without exception when the check reports the CLI is present. + TemporalCliLocator.EnsureAvailable(() => true); + } + + [Fact] + public void EnsureAvailable_ErrorMessage_ContainsInstallInstructions() + { + // Regression: the error must stay actionable — users must know where to get the CLI. + var ex = Assert.Throws( + () => TemporalCliLocator.EnsureAvailable(() => false)); + + Assert.Contains("docs.temporal.io/cli", ex.Message); + Assert.Contains("PATH", ex.Message); + } + + // ----------------------------------------------------------------------- + // AddTemporalCliServer — registration structure (PATH-independent) + // + // These tests use TemporalCliServerResource directly instead of calling + // AddTemporalCliServer so they do not depend on 'temporal' being installed + // on the test machine. + // ----------------------------------------------------------------------- + [Fact] + public void TemporalCliServerResource_Command_IsTemporalExecutable() + { + // The resource MUST use "temporal" as the Aspire executable command. + // Regression guard: a rename here would silently break all CLI server setups + // because Aspire would try to launch the wrong binary. + var resource = new TemporalCliServerResource("temporal-cli-server"); + + Assert.Equal("temporal", resource.Command); + } + + [Fact] + public void TemporalCliServerResource_DefaultArgs_ContainServerStartDev() + { + // Aspire launches the resource by appending args to the Command. + // The server subcommand must be present or temporal starts in the wrong mode. + var resource = new TemporalCliServerResource("temporal-cli-server"); + var args = TemporalArgsBuilder.BuildArgs(resource.Options); + + Assert.Contains("server", args); + Assert.Contains("start-dev", args); + } + + [Fact] + public void TemporalCliServerResource_DefaultArgs_ContainServicePort() + { + // The --port flag must map to the default service port so the Aspire-registered + // endpoint matches the port temporal actually binds. + var resource = new TemporalCliServerResource("temporal-cli-server"); + var args = TemporalArgsBuilder.BuildArgs(resource.Options); + + var portIndex = Array.IndexOf(args, "--port"); + Assert.True(portIndex >= 0, "Expected --port flag in args"); + Assert.Equal( + TemporalResourceConstants.DefaultServiceEndpointPort.ToString(System.Globalization.CultureInfo.InvariantCulture), + args[portIndex + 1]); + } + + [Fact] + public void TemporalCliServerResource_DefaultArgs_ContainUiPort() + { + // Regression: UI port must be emitted in CLI mode so the dashboard is reachable. + // This was added alongside the fix that wires the UI endpoint URL in the Aspire dashboard. + var resource = new TemporalCliServerResource("temporal-cli-server"); + var args = TemporalArgsBuilder.BuildArgs(resource.Options); + + var uiPortIndex = Array.IndexOf(args, "--ui-port"); + Assert.True(uiPortIndex >= 0, "Expected --ui-port flag in args"); + Assert.Equal( + TemporalResourceConstants.DefaultUIEndpointPort.ToString(System.Globalization.CultureInfo.InvariantCulture), + args[uiPortIndex + 1]); + } + + [Fact] + public void TemporalCliServerResource_CustomOptions_ReflectedInArgs() + { + var resource = new TemporalCliServerResource("temporal-cli-server"); + resource.Options.Namespace = "orders"; + resource.Options.CodecEndpoint = "http://localhost:8088"; + + var args = TemporalArgsBuilder.BuildArgs(resource.Options); + + Assert.Contains("orders", args); + Assert.Contains("http://localhost:8088", args); + } + + // ----------------------------------------------------------------------- + // AddTemporalCliServer — public path tests (PATH-independent via seam) + // ----------------------------------------------------------------------- + [Fact] + public void AddTemporalCliServer_Throws_WhenTemporalCliNotOnPath() + { + // Regression guard: the public extension method must throw early with an + // actionable error when the Temporal CLI is absent, rather than failing later + // with a cryptic FailedToStart from Aspire. The isTemporalCliAvailable seam + // lets tests simulate a machine without 'temporal' on PATH. + var appBuilder = DistributedApplication.CreateBuilder([]); + + var ex = Assert.Throws( + () => appBuilder.AddTemporalCliServer("temporal", configure: null, isTemporalCliAvailable: () => false)); + + Assert.Contains("temporal", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PATH", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void AddTemporalCliServer_RegistersExplicitPortsOnAllEndpoints() + { + // Regression: the CLI resource must register explicit host ports on all endpoints + // so the Aspire dashboard renders clickable URLs. Without an explicit port the + // dashboard assigns a random port and the link is either blank or wrong. + var appBuilder = DistributedApplication.CreateBuilder([]); + + var resourceBuilder = appBuilder.AddTemporalCliServer( + "temporal", + configure: options => + { + options.TargetHost = "0.0.0.0:17233"; + options.UIPort = 18233; + options.MetricsPort = 19233; + }, + isTemporalCliAvailable: () => true); + + var annotations = resourceBuilder.Resource.Annotations + .OfType() + .ToList(); + + var service = annotations.Single(e => e.Name == TemporalResourceConstants.ServiceEndpointName); + var ui = annotations.Single(e => e.Name == TemporalResourceConstants.UIEndpointName); + var metrics = annotations.Single(e => e.Name == TemporalResourceConstants.MetricsEndpointName); + + Assert.Equal(17233, service.Port); + Assert.Equal(18233, ui.Port); + Assert.Equal(19233, metrics.Port); + } +} diff --git a/tests/AspireIntegrations/TemporalEnvironmentHelperTests.cs b/tests/AspireIntegrations/TemporalEnvironmentHelperTests.cs new file mode 100644 index 0000000..426bc83 --- /dev/null +++ b/tests/AspireIntegrations/TemporalEnvironmentHelperTests.cs @@ -0,0 +1,131 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Temporal.Extensions.Aspire.Hosting; +using Xunit; + +namespace TemporalioSamples.Tests.AspireIntegrations; + +public class TemporalEnvironmentHelperTests +{ + [Fact] + public void AddEnvironmentVariables_DoesNotInjectDefaultNamespace() + { + var environmentVariables = new Dictionary(); + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + environmentVariables, + CancellationToken.None); + + var options = new TemporalResourceOptions + { + Namespace = "orders", + }; + + TemporalEnvironmentHelper.AddEnvironmentVariables( + context, + options, + "localhost:7233", + "http://localhost:8233"); + + Assert.Equal("localhost:7233", environmentVariables["TEMPORAL_ADDRESS"]); + Assert.Equal("http://localhost:8233", environmentVariables["TEMPORAL_UI_ADDRESS"]); + Assert.Equal("orders", environmentVariables["TEMPORAL_NAMESPACE"]); + Assert.False(environmentVariables.ContainsKey("TEMPORAL_DEFAULT_NAMESPACE")); + } + + [Fact] + public void AddEnvironmentVariables_UsesIndependentContexts() + { + var firstVariables = new Dictionary(); + var firstContext = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + firstVariables, + CancellationToken.None); + var firstOptions = new TemporalResourceOptions { Namespace = "alpha" }; + + TemporalEnvironmentHelper.AddEnvironmentVariables( + firstContext, + firstOptions, + "alpha:7233", + "http://alpha:8233"); + + var secondVariables = new Dictionary(); + var secondContext = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + secondVariables, + CancellationToken.None); + var secondOptions = new TemporalResourceOptions { Namespace = "beta" }; + + TemporalEnvironmentHelper.AddEnvironmentVariables( + secondContext, + secondOptions, + "beta:7233", + "http://beta:8233"); + + Assert.Equal("alpha", firstVariables["TEMPORAL_NAMESPACE"]); + Assert.Equal("beta", secondVariables["TEMPORAL_NAMESPACE"]); + Assert.False(firstVariables.ContainsKey("TEMPORAL_DEFAULT_NAMESPACE")); + Assert.False(secondVariables.ContainsKey("TEMPORAL_DEFAULT_NAMESPACE")); + } + + [Fact] + public void AddEnvironmentVariables_InjectsCodecAuth_WhenSet() + { + var environmentVariables = new Dictionary(); + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + environmentVariables, + CancellationToken.None); + + var options = new TemporalResourceOptions + { + Namespace = "default", + CodecAuth = "Bearer my-token", + }; + + TemporalEnvironmentHelper.AddEnvironmentVariables( + context, options, "localhost:7233", "http://localhost:8233"); + + Assert.Equal("Bearer my-token", environmentVariables["TEMPORAL_CODEC_AUTH"]); + } + + [Fact] + public void AddEnvironmentVariables_InjectsCodecEndpoint_WhenSet() + { + var environmentVariables = new Dictionary(); + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + environmentVariables, + CancellationToken.None); + + var options = new TemporalResourceOptions + { + Namespace = "default", + CodecEndpoint = "http://localhost:8088", + }; + + TemporalEnvironmentHelper.AddEnvironmentVariables( + context, options, "localhost:7233", "http://localhost:8233"); + + Assert.Equal("http://localhost:8088", environmentVariables["TEMPORAL_CODEC_ENDPOINT"]); + } + + [Fact] + public void AddEnvironmentVariables_OmitsCodecKeys_WhenNotSet() + { + // Regression: env vars must not be injected with null/empty values when codec is unused. + var environmentVariables = new Dictionary(); + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + environmentVariables, + CancellationToken.None); + + var options = new TemporalResourceOptions { Namespace = "default" }; + + TemporalEnvironmentHelper.AddEnvironmentVariables( + context, options, "localhost:7233", "http://localhost:8233"); + + Assert.False(environmentVariables.ContainsKey("TEMPORAL_CODEC_AUTH")); + Assert.False(environmentVariables.ContainsKey("TEMPORAL_CODEC_ENDPOINT")); + } +} diff --git a/tests/AspireIntegrations/TemporalHealthCheckHelperTests.cs b/tests/AspireIntegrations/TemporalHealthCheckHelperTests.cs new file mode 100644 index 0000000..1deaed8 --- /dev/null +++ b/tests/AspireIntegrations/TemporalHealthCheckHelperTests.cs @@ -0,0 +1,47 @@ +using Aspire.Hosting; +using Temporal.Extensions.Aspire.Hosting; +using Xunit; + +namespace TemporalioSamples.Tests.AspireIntegrations; + +public class TemporalHealthCheckHelperTests +{ + [Fact] + public async Task AccessorReturnsNull_BeforeConnectionStringEventFires() + { + // Regression: the old design attempted to connect inside the ConnectionStringAvailableEvent + // callback and threw when the port wasn't bound yet, causing FailedToStart. The new design + // has the accessor return null (Unhealthy "not yet initialized") until the first successful + // connect, either during warm-up retries or on a later health probe. + // + // This test ensures that calling the accessor immediately after registration — before any + // Aspire event has fired — returns null rather than throwing. + var appBuilder = DistributedApplication.CreateBuilder([]); + var resource = new TemporalCliServerResource("test-temporal"); + + var accessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor( + appBuilder, resource, "default"); + + var client = await accessor(CancellationToken.None); + + Assert.Null(client); + } + + [Fact] + public async Task AccessorIsIdempotent_WhenCalledMultipleTimes_BeforeEventFires() + { + // The accessor must be safe to call repeatedly (health checks probe on every interval). + // It must never throw when no connection string has been published yet. + var appBuilder = DistributedApplication.CreateBuilder([]); + var resource = new TemporalCliServerResource("test-temporal"); + + var accessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor( + appBuilder, resource, "default"); + + for (var i = 0; i < 3; i++) + { + var client = await accessor(CancellationToken.None); + Assert.Null(client); + } + } +} diff --git a/tests/AspireIntegrations/TemporalHealthCheckTests.cs b/tests/AspireIntegrations/TemporalHealthCheckTests.cs new file mode 100644 index 0000000..2d2a2ef --- /dev/null +++ b/tests/AspireIntegrations/TemporalHealthCheckTests.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Temporal.Extensions.Aspire.Hosting; +using Temporalio.Client; +using Xunit; + +namespace TemporalioSamples.Tests.AspireIntegrations; + +public class TemporalHealthCheckTests +{ + [Fact] + public async Task CheckHealthAsync_ReturnsUnhealthy_WhenClientAccessorReturnsNull() + { + // Regression: before the lazy-reconnect refactor, a null client meant the resource stayed + // permanently Unhealthy with no path to recovery. The fix ensures the description is + // "not yet initialized" (a transient state) rather than a hard failure. + var healthCheck = new TemporalHealthCheck(_ => Task.FromResult(null)); + + var result = await healthCheck.CheckHealthAsync(MakeContext(healthCheck), CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not yet initialized", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_ReturnsUnhealthy_WhenAccessorAlwaysReturnsNull_StatusIsTransient() + { + // A second probe with a still-null client also returns Unhealthy, confirming the accessor + // is called on every probe (lazy, not just once). + var callCount = 0; + var healthCheck = new TemporalHealthCheck(_ => + { + callCount++; + return Task.FromResult(null); + }); + + await healthCheck.CheckHealthAsync(MakeContext(healthCheck), CancellationToken.None); + await healthCheck.CheckHealthAsync(MakeContext(healthCheck), CancellationToken.None); + + Assert.Equal(2, callCount); + } + + [Fact] + public async Task CheckHealthAsync_PropagatesCancellation_WhenTokenCancelled() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var healthCheck = new TemporalHealthCheck(ct => + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(null); + }); + + await Assert.ThrowsAnyAsync( + () => healthCheck.CheckHealthAsync(MakeContext(healthCheck), cts.Token)); + } + + private static HealthCheckContext MakeContext(IHealthCheck instance) => + new() { Registration = new HealthCheckRegistration("temporal", instance, HealthStatus.Unhealthy, null) }; +} diff --git a/tests/AspireIntegrations/TemporalResourceOptionsTests.cs b/tests/AspireIntegrations/TemporalResourceOptionsTests.cs new file mode 100644 index 0000000..2792a4f --- /dev/null +++ b/tests/AspireIntegrations/TemporalResourceOptionsTests.cs @@ -0,0 +1,88 @@ +using Temporal.Extensions.Aspire.Hosting; +using Xunit; + +namespace TemporalioSamples.Tests.AspireIntegrations; + +public class TemporalResourceOptionsTests +{ + [Fact] + public void DefaultOptions_HaveExpectedPortValues() + { + var options = new TemporalResourceOptions(); + + Assert.Equal(TemporalResourceConstants.DefaultServiceEndpointPort, options.Port); + Assert.Equal(TemporalResourceConstants.DefaultUIEndpointPort, options.UIPort); + Assert.Equal(TemporalResourceConstants.DefaultMetricsEndpointPort, options.MetricsPort); + } + + [Fact] + public void DefaultOptions_HaveUIEnabled() + { + var options = new TemporalResourceOptions(); + + Assert.True(options.UI); + Assert.False(options.IsHeadless); + } + + [Fact] + public void Port_ParsedCorrectlyFromTargetHost() + { + var options = new TemporalResourceOptions { TargetHost = "0.0.0.0:7240" }; + + Assert.Equal(7240, options.Port); + } + + [Fact] + public void Ip_ParsedCorrectlyFromTargetHost() + { + var options = new TemporalResourceOptions { TargetHost = "127.0.0.1:7233" }; + + Assert.Equal("127.0.0.1", options.Ip); + } + + [Fact] + public void Port_ThrowsInvalidOperationException_WhenTargetHostIsEmpty() + { + var options = new TemporalResourceOptions { TargetHost = string.Empty }; + + Assert.Throws(() => _ = options.Port); + } + + [Fact] + public void Ip_ThrowsInvalidOperationException_WhenTargetHostIsEmpty() + { + var options = new TemporalResourceOptions { TargetHost = string.Empty }; + + Assert.Throws(() => _ = options.Ip); + } + + [Fact] + public void Port_ThrowsInvalidOperationException_WhenTargetHostLacksPort() + { + // Regression: TargetHost with no colon-separated port must not silently return 0. + var options = new TemporalResourceOptions { TargetHost = "localhost" }; + + Assert.Throws(() => _ = options.Port); + } + + [Fact] + public void AdditionalNamespaces_AlwaysIncludesPrimaryNamespace() + { + var options = new TemporalResourceOptions { Namespace = "orders" }; + options.AdditionalNamespaces = new List { "shipping", "payments" }; + + Assert.Contains("orders", options.AdditionalNamespaces); + Assert.Contains("shipping", options.AdditionalNamespaces); + Assert.Contains("payments", options.AdditionalNamespaces); + } + + [Fact] + public void AdditionalNamespaces_DeduplicatesPrimaryNamespace() + { + // Setting the primary namespace in the extra list must not create a duplicate. + var options = new TemporalResourceOptions { Namespace = "orders" }; + options.AdditionalNamespaces = new List { "orders", "shipping" }; + + Assert.Single(options.AdditionalNamespaces, ns => ns == "orders"); + } +} diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj index 5893aca..0505ca4 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -22,6 +22,7 @@ +