diff --git a/sample/AppHost/Program.cs b/sample/AppHost/Program.cs index 6352475..d64372d 100644 --- a/sample/AppHost/Program.cs +++ b/sample/AppHost/Program.cs @@ -16,6 +16,10 @@ .WithNamespace("test1", "test2") .WithDynamicConfigValue("frontend.enableUpdateWorkflowExecution", true); +var temporalPersistent = builder.AddTemporalServerContainer("temporalPersistent") + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent); + builder.AddProject("api") .WithReference(temporal); diff --git a/src/InfinityFlow.Aspire.Temporal/TemporalServerResourceExtensions.cs b/src/InfinityFlow.Aspire.Temporal/TemporalServerResourceExtensions.cs index 15dc53b..7f8a7ab 100644 --- a/src/InfinityFlow.Aspire.Temporal/TemporalServerResourceExtensions.cs +++ b/src/InfinityFlow.Aspire.Temporal/TemporalServerResourceExtensions.cs @@ -168,6 +168,47 @@ public static IResourceBuilder WithHeadlessUi( return builder; } + /// Adds a named volume for Temporal's SQLite data and configures persistence. + /// If was called, the volume mounts to that file's directory. + /// Otherwise defaults to /data/temporal.db. + /// The resource builder. + /// The volume name. Defaults to an auto-generated name based on the application and resource names. + /// A flag that indicates if this is a read-only volume. + public static IResourceBuilder WithDataVolume( + this IResourceBuilder builder, string? name = null, bool isReadOnly = false) + { + var (_, mountPath) = ResolveDbPath(builder); + return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), mountPath, isReadOnly); + } + + /// Adds a bind mount for Temporal's SQLite data and configures persistence. + /// If was called, the bind mount targets that file's directory. + /// Otherwise defaults to /data/temporal.db. + /// The resource builder. + /// The source directory on the host to mount into the container. + /// A flag that indicates if this is a read-only mount. + public static IResourceBuilder WithDataBindMount( + this IResourceBuilder builder, string source, bool isReadOnly = false) + { + var (_, mountPath) = ResolveDbPath(builder); + return builder.WithBindMount(source, mountPath, isReadOnly); + } + + private static (string DbFileName, string MountPath) ResolveDbPath( + IResourceBuilder builder) + { + var existing = builder.Resource.Annotations.OfType().LastOrDefault(); + if (existing is not null) + { + var dir = Path.GetDirectoryName(existing.FileName) ?? "/data"; + return (existing.FileName, dir); + } + + const string defaultDbPath = "/data/temporal.db"; + builder.Resource.Annotations.Add(new TemporalDbFileNameAnnotation(defaultDbPath)); + return (defaultDbPath, "/data"); + } + // --- Executable-specific endpoint methods --- /// Sets the gRPC service port for the Temporal executable. diff --git a/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalClientIntegrationTests.cs b/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalClientIntegrationTests.cs index 1691148..19be09f 100644 --- a/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalClientIntegrationTests.cs +++ b/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalClientIntegrationTests.cs @@ -13,6 +13,7 @@ namespace InfinityFlow.Aspire.Temporal.Tests; +[Collection("Integration")] [Trait("Category", "Integration")] public class TemporalClientIntegrationTests { @@ -20,11 +21,11 @@ public class TemporalClientIntegrationTests public async Task AddTemporalClient_ResolvesConnectionAndConnects() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalClient("temporal"); @@ -33,7 +34,7 @@ public async Task AddTemporalClient_ResolvesConnectionAndConnects() var client = host.Services.GetRequiredService(); Assert.NotNull(client); - Assert.Equal($"{address}:{port}", client.Connection.Options.TargetHost); + Assert.Equal(targetHost, client.Connection.Options.TargetHost); await host.StopAsync(ct); } @@ -42,11 +43,11 @@ public async Task AddTemporalClient_ResolvesConnectionAndConnects() public async Task AddTemporalClient_ConfigureClientSetsNamespace() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalClient("temporal", opts => { @@ -66,11 +67,11 @@ public async Task AddTemporalClient_ConfigureClientSetsNamespace() public async Task AddTemporalClient_ConfigureOptionsApplies() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalClient("temporal") .ConfigureOptions(opts => opts.Namespace = "configured-ns"); @@ -88,11 +89,11 @@ public async Task AddTemporalClient_ConfigureOptionsApplies() public async Task HealthCheck_ReturnsHealthy_WhenConnected() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalClient("temporal"); @@ -112,11 +113,11 @@ public async Task HealthCheck_ReturnsHealthy_WhenConnected() public async Task AddTemporalWorker_RegistersWorkflowAndActivities() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalWorker("temporal", "test-queue") .AddWorkflow() @@ -135,11 +136,11 @@ public async Task AddTemporalWorker_RegistersWorkflowAndActivities() public async Task AddTemporalWorker_ExecutesWorkflow() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalWorker("temporal", "e2e-queue") .AddWorkflow() @@ -163,11 +164,11 @@ public async Task AddTemporalWorker_ExecutesWorkflow() public async Task AddTemporalWorker_TransientActivities() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalWorker("temporal", "transient-queue") .AddWorkflow() @@ -191,11 +192,11 @@ public async Task AddTemporalWorker_TransientActivities() public async Task AddTemporalWorker_SingletonActivities() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalWorker("temporal", "singleton-queue") .AddWorkflow() @@ -219,11 +220,11 @@ public async Task AddTemporalWorker_SingletonActivities() public async Task AddTemporalWorker_ActivitiesInstance() { var ct = TestContext.Current.CancellationToken; - var (address, port, aspireApp) = await StartTemporalServer(cancellationToken: ct); + var (targetHost, aspireApp) = await StartTemporalServer(cancellationToken: ct); await using var _ = aspireApp; var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration["ConnectionStrings:temporal"] = $"{address}:{port}"; + hostBuilder.Configuration["ConnectionStrings:temporal"] = targetHost; hostBuilder.AddTemporalWorker("temporal", "instance-queue") .AddWorkflow() @@ -244,10 +245,10 @@ public async Task AddTemporalWorker_ActivitiesInstance() } /// - /// Starts a Temporal dev server via Aspire and returns (address, port, app). + /// Starts a Temporal dev server via Aspire and returns (targetHost, app). /// Each test gets a uniquely named resource to avoid container conflicts. /// - private static async Task<(string Address, int Port, DistributedApplication App)> StartTemporalServer( + private static async Task<(string TargetHost, DistributedApplication App)> StartTemporalServer( [System.Runtime.CompilerServices.CallerMemberName] string callerName = "", CancellationToken cancellationToken = default) { @@ -255,7 +256,9 @@ public async Task AddTemporalWorker_ActivitiesInstance() var builder = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); var temporal = builder.AddTemporalServerContainer(resourceName); - temporal.WithEndpoint(scheme: "http", targetPort: 7233, name: "grpc-direct", isProxied: false); + + // Make the server endpoint non-proxied so the Temporal gRPC client can connect directly. + temporal.WithEndpoint("server", e => { e.IsProxied = false; e.UriScheme = "http"; }); var app = await builder.BuildAsync(cancellationToken); @@ -265,17 +268,14 @@ public async Task AddTemporalWorker_ActivitiesInstance() await rns.WaitForResourceAsync(resourceName, KnownResourceStates.Running, cancellationToken) .WaitAsync(TimeSpan.FromSeconds(120), cancellationToken); - var directEndpoint = temporal.Resource.Annotations - .OfType() - .Single(e => e.Name == "grpc-direct"); - - var address = directEndpoint.AllocatedEndpoint!.Address; - var port = directEndpoint.AllocatedEndpoint!.Port; + var serverEndpoint = temporal.GetEndpoint("server"); + var uri = new Uri(serverEndpoint.Url); + var targetHost = $"{uri.Host}:{uri.Port}"; // Allow server to fully initialize await Task.Delay(3000, cancellationToken); - return (address, port, app); + return (targetHost, app); } [Workflow] diff --git a/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalFluentApiTests.cs b/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalFluentApiTests.cs index 57e47ae..63b014c 100644 --- a/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalFluentApiTests.cs +++ b/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalFluentApiTests.cs @@ -165,4 +165,88 @@ public void WithUiPublicPath_AddsAnnotation() var annotation = temporal.Resource.Annotations.OfType().Single(); Assert.Equal("/temporal", annotation.PublicPath); } + + [Fact] + public void WithDataVolume_AddsDbFileNameAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDataVolume(); + var annotation = temporal.Resource.Annotations.OfType().Single(); + Assert.Equal("/data/temporal.db", annotation.FileName); + } + + [Fact] + public void WithDataVolume_AddsVolumeAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDataVolume(); + var volumes = temporal.Resource.Annotations.OfType() + .Where(a => a.Target == "/data") + .ToList(); + Assert.Single(volumes); + Assert.Equal(ContainerMountType.Volume, volumes[0].Type); + } + + [Fact] + public void WithDataVolume_CustomName_UsesProvidedName() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDataVolume("my-custom-volume"); + var volume = temporal.Resource.Annotations.OfType() + .Single(a => a.Target == "/data"); + Assert.Equal("my-custom-volume", volume.Source); + } + + [Fact] + public void WithDataBindMount_AddsDbFileNameAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDataBindMount("/host/data"); + var annotation = temporal.Resource.Annotations.OfType().Single(); + Assert.Equal("/data/temporal.db", annotation.FileName); + } + + [Fact] + public void WithDataBindMount_AddsBindMountAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDataBindMount("/host/data"); + var mount = temporal.Resource.Annotations.OfType() + .Single(a => a.Target == "/data"); + Assert.Equal(ContainerMountType.BindMount, mount.Type); + Assert.Equal("/host/data", mount.Source); + } + + [Fact] + public void WithDataVolume_RespectsExistingDbFileName() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDbFileName("/custom/path.db") + .WithDataVolume(); + var annotation = temporal.Resource.Annotations.OfType().Single(); + Assert.Equal("/custom/path.db", annotation.FileName); + var volume = temporal.Resource.Annotations.OfType() + .Single(a => a.Type == ContainerMountType.Volume); + Assert.Equal("/custom", volume.Target); + } + + [Fact] + public void WithDataBindMount_RespectsExistingDbFileName() + { + var builder = DistributedApplication.CreateBuilder(); + var temporal = builder.AddTemporalServerContainer("test") + .WithDbFileName("/custom/path.db") + .WithDataBindMount("/host/data"); + var annotation = temporal.Resource.Annotations.OfType().Single(); + Assert.Equal("/custom/path.db", annotation.FileName); + var mount = temporal.Resource.Annotations.OfType() + .Single(a => a.Type == ContainerMountType.BindMount); + Assert.Equal("/custom", mount.Target); + } } diff --git a/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalIntegrationTests.cs b/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalIntegrationTests.cs index 21366de..a739de4 100644 --- a/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalIntegrationTests.cs +++ b/tests/InfinityFlow.Aspire.Temporal.Tests/TemporalIntegrationTests.cs @@ -2,7 +2,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using InfinityFlow.Aspire.Temporal; -using InfinityFlow.Aspire.Temporal.Annotations; using Microsoft.Extensions.DependencyInjection; using Temporalio.Api.Enums.V1; using Temporalio.Api.OperatorService.V1; @@ -11,6 +10,7 @@ namespace InfinityFlow.Aspire.Temporal.Tests; +[Collection("Integration")] [Trait("Category", "Integration")] public class TemporalIntegrationTests { @@ -27,10 +27,8 @@ public async Task SearchAttributes_AreRegisteredOnServer() .WithSearchAttribute("CustomDatetime", SearchAttributeType.Datetime) .WithNamespace("integration-test"); - // Add a non-proxied HTTP endpoint for direct gRPC access in tests. - // The main HTTPS endpoint uses an Aspire TLS proxy, which the - // Temporal gRPC client cannot easily consume in test scenarios. - temporal.WithEndpoint(scheme: "http", targetPort: 7233, name: "grpc-direct", isProxied: false); + // Make the server endpoint non-proxied so the Temporal gRPC client can connect directly. + temporal.WithEndpoint("server", e => { e.IsProxied = false; e.UriScheme = "http"; }); await using var app = await builder.BuildAsync(ct); @@ -40,18 +38,14 @@ public async Task SearchAttributes_AreRegisteredOnServer() await rns.WaitForResourceAsync("temporal-search", KnownResourceStates.Running, ct) .WaitAsync(TimeSpan.FromSeconds(120), ct); - var directEndpoint = temporal.Resource.Annotations - .OfType() - .Single(e => e.Name == "grpc-direct"); - - var address = directEndpoint.AllocatedEndpoint!.Address; - var port = directEndpoint.AllocatedEndpoint!.Port; + var serverEndpoint = temporal.GetEndpoint("server"); + var uri = new Uri(serverEndpoint.Url); // Allow server to fully initialize search attributes await Task.Delay(3000, ct); var client = await TemporalClient.ConnectAsync( - new TemporalClientConnectOptions($"{address}:{port}") + new TemporalClientConnectOptions($"{uri.Host}:{uri.Port}") { Namespace = "default", }); @@ -80,7 +74,7 @@ public async Task Namespace_IsCreatedOnServer() var temporal = builder.AddTemporalServerContainer("temporal-ns") .WithNamespace("custom-ns-1", "custom-ns-2"); - temporal.WithEndpoint(scheme: "http", targetPort: 7233, name: "grpc-direct", isProxied: false); + temporal.WithEndpoint("server", e => { e.IsProxied = false; e.UriScheme = "http"; }); await using var app = await builder.BuildAsync(ct); @@ -90,17 +84,13 @@ public async Task Namespace_IsCreatedOnServer() await rns.WaitForResourceAsync("temporal-ns", KnownResourceStates.Running, ct) .WaitAsync(TimeSpan.FromSeconds(120), ct); - var directEndpoint = temporal.Resource.Annotations - .OfType() - .Single(e => e.Name == "grpc-direct"); - - var address = directEndpoint.AllocatedEndpoint!.Address; - var port = directEndpoint.AllocatedEndpoint!.Port; + var serverEndpoint = temporal.GetEndpoint("server"); + var uri = new Uri(serverEndpoint.Url); await Task.Delay(3000, ct); var client = await TemporalClient.ConnectAsync( - new TemporalClientConnectOptions($"{address}:{port}") + new TemporalClientConnectOptions($"{uri.Host}:{uri.Port}") { Namespace = "custom-ns-1", });