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); + } +}