diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e9d791ad5d..23bcd0cf73 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -56,6 +56,8 @@ jobs:
name: Hosting.Redis
- project: tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj
name: Hosting.Sdk
+ - project: tests/Aspire.Hosting.Seq.Tests/Aspire.Hosting.Seq.Tests.csproj
+ name: Hosting.Seq
- project: tests/Aspire.Hosting.SqlServer.Tests/Aspire.Hosting.SqlServer.Tests.csproj
name: Hosting.SqlServer
- project: tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj
diff --git a/Aspire.sln b/Aspire.sln
index 5342d4697d..a5eef0ef90 100644
--- a/Aspire.sln
+++ b/Aspire.sln
@@ -637,6 +637,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.MongoDB.Driver.v3.Te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stress.Empty", "playground\Stress\Stress.Empty\Stress.Empty.csproj", "{6C4B55AD-5D98-452D-B71D-CAF628E822B0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Seq.Tests", "tests\Aspire.Hosting.Seq.Tests\Aspire.Hosting.Seq.Tests.csproj", "{CA86754E-3AED-4937-BE7B-14EF9929E245}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1671,6 +1673,10 @@ Global
{6C4B55AD-5D98-452D-B71D-CAF628E822B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6C4B55AD-5D98-452D-B71D-CAF628E822B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6C4B55AD-5D98-452D-B71D-CAF628E822B0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CA86754E-3AED-4937-BE7B-14EF9929E245}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA86754E-3AED-4937-BE7B-14EF9929E245}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA86754E-3AED-4937-BE7B-14EF9929E245}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA86754E-3AED-4937-BE7B-14EF9929E245}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1977,6 +1983,7 @@ Global
{FD53B608-138D-8FB1-AA57-9B8CD42CF13E} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2}
{223AF8EB-3A4E-E778-4EBD-6E4876C308B6} = {C424395C-1235-41A4-BF55-07880A04368C}
{6C4B55AD-5D98-452D-B71D-CAF628E822B0} = {CFDA7AC5-251C-43C7-B334-71AE8040A147}
+ {CA86754E-3AED-4937-BE7B-14EF9929E245} = {830A89EC-4029-4753-B25A-068BAE37DEC7}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4}
diff --git a/src/Aspire.Hosting.Seq/SeqBuilderExtensions.cs b/src/Aspire.Hosting.Seq/SeqBuilderExtensions.cs
index a6c1039989..d4a08c0777 100644
--- a/src/Aspire.Hosting.Seq/SeqBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Seq/SeqBuilderExtensions.cs
@@ -23,13 +23,14 @@ public static class SeqBuilderExtensions
/// The .
/// The name to give the resource.
/// The host port for the Seq server.
-#pragma warning disable RS0016 // Add public types and members to the declared API
public static IResourceBuilder AddSeq(
-#pragma warning restore RS0016 // Add public types and members to the declared API
this IDistributedApplicationBuilder builder,
string name,
int? port = null)
{
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+
var seqResource = new SeqResource(name);
var resourceBuilder = builder.AddResource(seqResource)
.WithHttpEndpoint(port: port, targetPort: 80, name: SeqResource.PrimaryEndpointName)
@@ -48,7 +49,11 @@ public static IResourceBuilder AddSeq(
/// A flag that indicates if this is a read-only volume.
/// The .
public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null, bool isReadOnly = false)
- => builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), SeqContainerDataDirectory, isReadOnly);
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), SeqContainerDataDirectory, isReadOnly);
+ }
///
/// Adds a bind mount for the data folder to a Seq container resource.
@@ -58,5 +63,10 @@ public static IResourceBuilder WithDataVolume(this IResourceBuilder
/// A flag that indicates if this is a read-only mount.
/// The .
public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source, bool isReadOnly = false)
- => builder.WithBindMount(source, SeqContainerDataDirectory, isReadOnly);
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(source);
+
+ return builder.WithBindMount(source, SeqContainerDataDirectory, isReadOnly);
+ }
}
diff --git a/tests/Aspire.Hosting.Seq.Tests/AddSeqTests.cs b/tests/Aspire.Hosting.Seq.Tests/AddSeqTests.cs
new file mode 100644
index 0000000000..116d602af1
--- /dev/null
+++ b/tests/Aspire.Hosting.Seq.Tests/AddSeqTests.cs
@@ -0,0 +1,165 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net.Sockets;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Aspire.Hosting.Seq.Tests;
+
+public class AddSeqTests
+{
+ [Fact]
+ public void AddSeqContainerWithDefaultsAddsAnnotationMetadata()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddSeq("mySeq").PublishAsContainer();
+
+ using var app = appBuilder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var containerResource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("mySeq", containerResource.Name);
+
+ var endpoint = Assert.Single(containerResource.Annotations.OfType());
+ Assert.Equal(80, endpoint.TargetPort);
+ Assert.False(endpoint.IsExternal);
+ Assert.Equal("http", endpoint.Name);
+ Assert.Null(endpoint.Port);
+ Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
+ Assert.Equal("http", endpoint.Transport);
+ Assert.Equal("http", endpoint.UriScheme);
+
+ var containerAnnotation = Assert.Single(containerResource.Annotations.OfType());
+ Assert.Equal(SeqContainerImageTags.Tag, containerAnnotation.Tag);
+ Assert.Equal(SeqContainerImageTags.Image, containerAnnotation.Image);
+ Assert.Equal(SeqContainerImageTags.Registry, containerAnnotation.Registry);
+ }
+
+ [Fact]
+ public void AddSeqContainerAddsAnnotationMetadata()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddSeq("mySeq", port: 9813);
+
+ using var app = appBuilder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var containerResource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("mySeq", containerResource.Name);
+
+ var endpoint = Assert.Single(containerResource.Annotations.OfType());
+ Assert.Equal(80, endpoint.TargetPort);
+ Assert.False(endpoint.IsExternal);
+ Assert.Equal("http", endpoint.Name);
+ Assert.Equal(9813, endpoint.Port);
+ Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
+ Assert.Equal("http", endpoint.Transport);
+ Assert.Equal("http", endpoint.UriScheme);
+
+ var containerAnnotation = Assert.Single(containerResource.Annotations.OfType());
+ Assert.Equal(SeqContainerImageTags.Tag, containerAnnotation.Tag);
+ Assert.Equal(SeqContainerImageTags.Image, containerAnnotation.Image);
+ Assert.Equal(SeqContainerImageTags.Registry, containerAnnotation.Registry);
+ }
+
+ [Fact]
+ public async Task SeqCreatesConnectionString()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddSeq("mySeq")
+ .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000));
+
+ using var app = appBuilder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var connectionStringResource = Assert.Single(appModel.Resources.OfType());
+ var connectionString = await connectionStringResource.GetConnectionStringAsync(default);
+ Assert.Equal("{mySeq.bindings.http.url}", connectionStringResource.ConnectionStringExpression.ValueExpression);
+ Assert.StartsWith("http://localhost:2000", connectionString);
+ }
+
+ [Fact]
+ public async Task VerifyManifest()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var seq = builder.AddSeq("seq");
+
+ var manifest = await ManifestUtils.GetManifest(seq.Resource);
+
+ var expectedManifest = $$"""
+ {
+ "type": "container.v0",
+ "connectionString": "{seq.bindings.http.url}",
+ "image": "{{SeqContainerImageTags.Registry}}/{{SeqContainerImageTags.Image}}:{{SeqContainerImageTags.Tag}}",
+ "env": {
+ "ACCEPT_EULA": "Y"
+ },
+ "bindings": {
+ "http": {
+ "scheme": "http",
+ "protocol": "tcp",
+ "transport": "http",
+ "targetPort": 80
+ }
+ }
+ }
+ """;
+ Assert.Equal(expectedManifest, manifest.ToString());
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithDataVolumeAddsVolumeAnnotation(bool? isReadOnly)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var seq = builder.AddSeq("mySeq");
+ if (isReadOnly.HasValue)
+ {
+ seq.WithDataVolume(isReadOnly: isReadOnly.Value);
+ }
+ else
+ {
+ seq.WithDataVolume();
+ }
+
+ var volumeAnnotation = seq.Resource.Annotations.OfType().Single();
+
+ Assert.Equal($"{builder.GetVolumePrefix()}-mySeq-data", volumeAnnotation.Source);
+ Assert.Equal("/data", volumeAnnotation.Target);
+ Assert.Equal(ContainerMountType.Volume, volumeAnnotation.Type);
+ Assert.Equal(isReadOnly ?? false, volumeAnnotation.IsReadOnly);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithDataBindMountAddsMountAnnotation(bool? isReadOnly)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var seq = builder.AddSeq("mySeq");
+ if (isReadOnly.HasValue)
+ {
+ seq.WithDataBindMount("mydata", isReadOnly: isReadOnly.Value);
+ }
+ else
+ {
+ seq.WithDataBindMount("mydata");
+ }
+
+ var volumeAnnotation = seq.Resource.Annotations.OfType().Single();
+
+ Assert.Equal(Path.Combine(builder.AppHostDirectory, "mydata"), volumeAnnotation.Source);
+ Assert.Equal("/data", volumeAnnotation.Target);
+ Assert.Equal(ContainerMountType.BindMount, volumeAnnotation.Type);
+ Assert.Equal(isReadOnly ?? false, volumeAnnotation.IsReadOnly);
+ }
+}
diff --git a/tests/Aspire.Hosting.Seq.Tests/Aspire.Hosting.Seq.Tests.csproj b/tests/Aspire.Hosting.Seq.Tests/Aspire.Hosting.Seq.Tests.csproj
new file mode 100644
index 0000000000..6e377cd6ea
--- /dev/null
+++ b/tests/Aspire.Hosting.Seq.Tests/Aspire.Hosting.Seq.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(DefaultTargetFramework)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs b/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs
new file mode 100644
index 0000000000..860c1998a6
--- /dev/null
+++ b/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs
@@ -0,0 +1,180 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using System.Text.Json;
+using Aspire.Components.Common.Tests;
+using Aspire.Hosting.Tests.Utils;
+using Aspire.Hosting.Utils;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Aspire.Hosting.Seq.Tests;
+
+public class SeqFunctionalTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact]
+ [RequiresDocker]
+ public async Task VerifySeqResource()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+
+ var seq = builder.AddSeq("seq");
+
+ using var app = builder.Build();
+
+ await app.StartAsync();
+
+ await app.WaitForTextAsync("Seq listening on", seq.Resource.Name);
+
+ var seqUrl = await seq.Resource.ConnectionStringExpression.GetValueAsync(default);
+
+ Assert.NotNull(seqUrl);
+
+ var client = CreateClient(seqUrl);
+
+ await CreateTestDataAsync(client, default);
+ }
+
+ private static HttpClient CreateClient(string url)
+ {
+ HttpClient client = new()
+ {
+ BaseAddress = new Uri(url)
+ };
+ return client;
+ }
+
+ private static async Task CreateTestDataAsync(HttpClient httpClient, CancellationToken token)
+ {
+ var payload = """{"@t": "2025-02-07T12:00:00Z", "@l": "Information", "@mt": "User {Username} logged in.", "Username": "johndoe"}""";
+
+ var content = new StringContent(payload, Encoding.UTF8, "application/json");
+
+ var ingestResponse = await httpClient.PostAsync("/ingest/clef", content, token);
+ ingestResponse.EnsureSuccessStatusCode();
+
+ var response = await httpClient.GetAsync("/api/events?filter=Username='johndoe'", token);
+ response.EnsureSuccessStatusCode();
+ var reponseContent = await response.Content.ReadAsStringAsync(token);
+
+ var jsonDocument = JsonDocument.Parse(reponseContent);
+ var doc = jsonDocument.RootElement.EnumerateArray().FirstOrDefault();
+ Assert.Equal("Information", doc.GetProperty("Level").GetString());
+
+ var property = doc.GetProperty("Properties").EnumerateArray().FirstOrDefault();
+ Assert.Equal("Username", property.GetProperty("Name").GetString());
+ Assert.Equal("johndoe", property.GetProperty("Value").GetString());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ [RequiresDocker]
+ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume)
+ {
+ string? volumeName = null;
+ string? bindMountPath = null;
+
+ try
+ {
+ using var builder1 = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var seq1 = builder1.AddSeq("seq1");
+
+ if (useVolume)
+ {
+ // Use a deterministic volume name to prevent them from exhausting the machines if deletion fails
+ volumeName = VolumeNameGenerator.Generate(seq1, nameof(WithDataShouldPersistStateBetweenUsages));
+
+ // if the volume already exists (because of a crashing previous run), delete it
+ DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: true);
+ seq1.WithDataVolume(volumeName);
+ }
+ else
+ {
+ bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ seq1.WithDataBindMount(bindMountPath);
+ }
+
+ using (var app = builder1.Build())
+ {
+ await app.StartAsync();
+
+ await app.WaitForTextAsync("Seq listening on", seq1.Resource.Name);
+
+ try
+ {
+ var seqUrl = await seq1.Resource.ConnectionStringExpression.GetValueAsync(default);
+
+ Assert.NotNull(seqUrl);
+
+ var client = CreateClient(seqUrl);
+
+ await CreateTestDataAsync(client, default);
+ }
+ finally
+ {
+ // Stops the container, or the Volume would still be in use
+ await app.StopAsync();
+ }
+ }
+
+ using var builder2 = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+
+ var seq2 = builder2.AddSeq("seq2");
+
+ if (useVolume)
+ {
+ seq2.WithDataVolume(volumeName);
+ }
+ else
+ {
+ seq2.WithDataBindMount(bindMountPath!);
+ }
+
+ using (var app = builder2.Build())
+ {
+ await app.StartAsync();
+
+ await app.WaitForTextAsync("Seq listening on", seq2.Resource.Name);
+
+ try
+ {
+ var seqUrl = await seq2.Resource.ConnectionStringExpression.GetValueAsync(default);
+
+ Assert.NotNull(seqUrl);
+
+ var client = CreateClient(seqUrl);
+
+ await CreateTestDataAsync(client, default);
+ }
+ finally
+ {
+ // Stops the container, or the Volume would still be in use
+ await app.StopAsync();
+ }
+
+ }
+
+ }
+ finally
+ {
+ if (volumeName is not null)
+ {
+ DockerUtils.AttemptDeleteDockerVolume(volumeName);
+ }
+
+ if (bindMountPath is not null)
+ {
+ try
+ {
+ Directory.Delete(bindMountPath, recursive: true);
+ }
+ catch
+ {
+ // Don't fail test if we can't clean the temporary folder
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Aspire.Hosting.Seq.Tests/SeqPublicApiTests.cs b/tests/Aspire.Hosting.Seq.Tests/SeqPublicApiTests.cs
new file mode 100644
index 0000000000..031e8de6e5
--- /dev/null
+++ b/tests/Aspire.Hosting.Seq.Tests/SeqPublicApiTests.cs
@@ -0,0 +1,83 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+using Xunit;
+
+namespace Aspire.Hosting.Seq.Tests;
+
+public class SeqPublicApiTests
+{
+ [Fact]
+ public void AddSeqContainerShouldThrowWhenBuilderIsNull()
+ {
+ IDistributedApplicationBuilder builder = null!;
+ const string name = "Seq";
+
+ var action = () => builder.AddSeq(name);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSeqContainerShouldThrowWhenNameIsNull()
+ {
+ var builder = DistributedApplication.CreateBuilder([]);
+ string name = null!;
+
+ var action = () => builder.AddSeq(name);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithDataVolumeShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithDataVolume();
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithDataBindMountShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ const string source = "/seq/data";
+
+ var action = () => builder.WithDataBindMount(source);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithDataBindMountShouldThrowWhenSourceIsNull()
+ {
+ var builderResource = TestDistributedApplicationBuilder.Create();
+ var qdrant = builderResource.AddSeq("Seq");
+ string source = null!;
+
+ var action = () => qdrant.WithDataBindMount(source);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(source), exception.ParamName);
+ }
+
+ [Fact]
+ public void CtorSeqResourceShouldThrowWhenNameIsNull()
+ {
+ var distributedApplicationBuilder = DistributedApplication.CreateBuilder([]);
+ string name = null!;
+
+ var action = () => new SeqResource(name);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+}