Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sample/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
.WithNamespace("test1", "test2")
.WithDynamicConfigValue("frontend.enableUpdateWorkflowExecution", true);

var temporalPersistent = builder.AddTemporalServerContainer("temporalPersistent")
.WithDataVolume()
.WithLifetime(ContainerLifetime.Persistent);

builder.AddProject<Projects.Api>("api")
.WithReference(temporal);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,47 @@ public static IResourceBuilder<TemporalServerContainerResource> WithHeadlessUi(
return builder;
}

/// <summary>Adds a named volume for Temporal's SQLite data and configures persistence.
/// If <see cref="WithDbFileName{T}"/> was called, the volume mounts to that file's directory.
/// Otherwise defaults to <c>/data/temporal.db</c>.</summary>
/// <param name="builder">The resource builder.</param>
/// <param name="name">The volume name. Defaults to an auto-generated name based on the application and resource names.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only volume.</param>
public static IResourceBuilder<TemporalServerContainerResource> WithDataVolume(
this IResourceBuilder<TemporalServerContainerResource> builder, string? name = null, bool isReadOnly = false)
{
var (_, mountPath) = ResolveDbPath(builder);
return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), mountPath, isReadOnly);
}

/// <summary>Adds a bind mount for Temporal's SQLite data and configures persistence.
/// If <see cref="WithDbFileName{T}"/> was called, the bind mount targets that file's directory.
/// Otherwise defaults to <c>/data/temporal.db</c>.</summary>
/// <param name="builder">The resource builder.</param>
/// <param name="source">The source directory on the host to mount into the container.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only mount.</param>
public static IResourceBuilder<TemporalServerContainerResource> WithDataBindMount(
this IResourceBuilder<TemporalServerContainerResource> builder, string source, bool isReadOnly = false)
{
var (_, mountPath) = ResolveDbPath(builder);
return builder.WithBindMount(source, mountPath, isReadOnly);
}

private static (string DbFileName, string MountPath) ResolveDbPath(
IResourceBuilder<TemporalServerContainerResource> builder)
{
var existing = builder.Resource.Annotations.OfType<TemporalDbFileNameAnnotation>().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 ---

/// <summary>Sets the gRPC service port for the Temporal executable.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@

namespace InfinityFlow.Aspire.Temporal.Tests;

[Collection("Integration")]
[Trait("Category", "Integration")]
public class TemporalClientIntegrationTests
{
[Fact]
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");

Expand All @@ -33,7 +34,7 @@ public async Task AddTemporalClient_ResolvesConnectionAndConnects()

var client = host.Services.GetRequiredService<ITemporalClient>();
Assert.NotNull(client);
Assert.Equal($"{address}:{port}", client.Connection.Options.TargetHost);
Assert.Equal(targetHost, client.Connection.Options.TargetHost);

await host.StopAsync(ct);
}
Expand All @@ -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 =>
{
Expand All @@ -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");
Expand All @@ -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");

Expand All @@ -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<TestWorkflow>()
Expand All @@ -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<TestWorkflow>()
Expand All @@ -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<TestWorkflow>()
Expand All @@ -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<TestWorkflow>()
Expand All @@ -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<TestWorkflow>()
Expand All @@ -244,18 +245,20 @@ public async Task AddTemporalWorker_ActivitiesInstance()
}

/// <summary>
/// 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.
/// </summary>
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)
{
var resourceName = $"t-{callerName}".ToLowerInvariant().Replace("_", "-");
var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.TestAppHost>(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);

Expand All @@ -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<EndpointAnnotation>()
.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]
Expand Down
84 changes: 84 additions & 0 deletions tests/InfinityFlow.Aspire.Temporal.Tests/TemporalFluentApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,88 @@ public void WithUiPublicPath_AddsAnnotation()
var annotation = temporal.Resource.Annotations.OfType<TemporalUiPublicPathAnnotation>().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<TemporalDbFileNameAnnotation>().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<ContainerMountAnnotation>()
.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<ContainerMountAnnotation>()
.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<TemporalDbFileNameAnnotation>().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<ContainerMountAnnotation>()
.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<TemporalDbFileNameAnnotation>().Single();
Assert.Equal("/custom/path.db", annotation.FileName);
var volume = temporal.Resource.Annotations.OfType<ContainerMountAnnotation>()
.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<TemporalDbFileNameAnnotation>().Single();
Assert.Equal("/custom/path.db", annotation.FileName);
var mount = temporal.Resource.Annotations.OfType<ContainerMountAnnotation>()
.Single(a => a.Type == ContainerMountType.BindMount);
Assert.Equal("/custom", mount.Target);
}
}
Loading
Loading