diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index b2d86ca72e5..69ee54260fd 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -730,10 +730,11 @@ internal static async Task AspireStartAsync( : "$ASPIRE_E2E_WORKSPACE/_aspire-start.json"; var isolatedFlag = isolated ? " --isolated" : ""; + var startupTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalSeconds)); // Keep aspire start as a single shell pipeline so tee captures the exact JSON emitted to the terminal while // pipefail preserves the real CLI exit code instead of letting tee mask build/startup failures. - await auto.TypeAsync($"(set -o pipefail; aspire start{isolatedFlag} --format json | tee \"{jsonFile}\")"); + await auto.TypeAsync($"(set -o pipefail; ASPIRE_CLI_START_TIMEOUT={startupTimeoutSeconds.ToString(CultureInfo.InvariantCulture)} aspire start{isolatedFlag} --format json | tee \"{jsonFile}\")"); await auto.EnterAsync(); // Wait for the command to finish — check for success or error exit. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index da85d0f916f..a5ef6a004e3 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -189,6 +189,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( bool mountDockerSocket = false, TemporaryWorkspace? workspace = null, IEnumerable? additionalVolumes = null, + string? network = null, int width = 160, int height = 48, [CallerMemberName] string testName = "") @@ -231,6 +232,11 @@ internal static Hex1bTerminal CreateDockerTestTerminal( c.MountDockerSocket = true; } + if (network is not null) + { + c.Network = network; + } + if (workspace is not null) { c.Volumes.Add($"{workspace.WorkspaceRoot.FullName}:/workspace/{workspace.WorkspaceRoot.Name}"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PersistentContainerEndToEndTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PersistentContainerEndToEndTests.cs new file mode 100644 index 00000000000..82855b503db --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/PersistentContainerEndToEndTests.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +public sealed class PersistentContainerEndToEndTests(ITestOutputHelper output) +{ + private const string ProjectName = "PersistenceE2E"; + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task PersistentContainersPreserveDataAcrossAppHostRuns() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + using var workspace = TemporaryWorkspace.Create(output); + + // The AppHost runs inside the E2E container while DCP starts backing containers through the host Docker socket. + // Host networking lets project resources connect to the Docker-published ports that Aspire puts in connection strings. + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace, network: "host"); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + var appHostCode = $$""" + #pragma warning disable ASPIREPERSISTENCE001 + + var builder = DistributedApplication.CreateBuilder(args); + + var redis = builder.AddRedis("redis") + .WithPersistentLifetime(); + + var postgres = builder.AddPostgres("postgres") + .WithPersistentLifetime(); + var postgresDatabase = postgres.AddDatabase("pgdb"); + + var storage = builder.AddAzureStorage("storage") + .RunAsEmulator(container => container.WithPersistentLifetime()); + var blobs = storage.AddBlobs("blobs"); + + builder.AddProject("server") + .WithReference(redis) + .WithReference(postgresDatabase) + .WithReference(blobs) + .WaitFor(redis) + .WaitFor(postgresDatabase) + .WaitFor(blobs) + .WithExternalHttpEndpoints(); + + builder.Build().Run(); + """; + + var apiProgramCode = """ + using Azure.Storage.Blobs; + using Npgsql; + using StackExchange.Redis; + + const string Marker = "persistent-container-value"; + const string RedisKey = "persistent-container-key"; + + var builder = WebApplication.CreateBuilder(args); + builder.AddServiceDefaults(); + builder.AddRedisClient("redis"); + builder.AddNpgsqlDataSource("pgdb"); + builder.AddAzureBlobServiceClient("blobs"); + + var app = builder.Build(); + app.MapDefaultEndpoints(); + + app.MapGet("/write", async (IConnectionMultiplexer redis, NpgsqlDataSource postgres, BlobServiceClient blobService) => + { + await redis.GetDatabase().StringSetAsync(RedisKey, Marker); + + await using (var connection = await postgres.OpenConnectionAsync()) + await using (var command = connection.CreateCommand()) + { + command.CommandText = "CREATE TABLE IF NOT EXISTS persistence_check (id integer PRIMARY KEY, value text NOT NULL);" + + "INSERT INTO persistence_check (id, value) VALUES (1, 'persistent-container-value') " + + "ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value;"; + await command.ExecuteNonQueryAsync(); + } + + var container = blobService.GetBlobContainerClient("persistence-check"); + await container.CreateIfNotExistsAsync(); + await container.GetBlobClient("marker").UploadAsync(BinaryData.FromString(Marker), overwrite: true); + + return Results.Ok("PERSISTENCE_WRITE_OK"); + }); + + app.MapGet("/verify", async (IConnectionMultiplexer redis, NpgsqlDataSource postgres, BlobServiceClient blobService) => + { + var redisValue = await redis.GetDatabase().StringGetAsync(RedisKey); + if (redisValue != Marker) + { + return Results.Problem($"Redis value was '{redisValue}'."); + } + + await using (var connection = await postgres.OpenConnectionAsync()) + await using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT value FROM persistence_check WHERE id = 1"; + var postgresValue = (await command.ExecuteScalarAsync())?.ToString(); + if (!StringComparer.Ordinal.Equals(postgresValue, Marker)) + { + return Results.Problem($"PostgreSQL value was '{postgresValue}'."); + } + } + + var blob = blobService.GetBlobContainerClient("persistence-check").GetBlobClient("marker"); + var blobContent = (await blob.DownloadContentAsync()).Value.Content.ToString(); + if (blobContent != Marker) + { + return Results.Problem($"Azure Storage blob value was '{blobContent}'."); + } + + return Results.Ok("PERSISTENCE_VERIFY_OK"); + }); + + app.Run(); + """; + + await auto.ScaffoldK8sDeployProjectAsync( + counter, + ProjectName, + Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName), + appHostHostingPackages: ["Aspire.Hosting.Redis", "Aspire.Hosting.PostgreSQL", "Aspire.Hosting.Azure.Storage"], + apiClientPackages: ["Aspire.StackExchange.Redis", "Aspire.Npgsql", "Aspire.Azure.Storage.Blobs"], + appHostCode: appHostCode, + apiProgramCode: apiProgramCode, + output: output); + + await auto.AspireStartAsync(counter, TimeSpan.FromMinutes(5)); + await VerifyEndpointAsync(auto, counter, "/write", "PERSISTENCE_WRITE_OK"); + await auto.AspireStopAsync(counter); + + await auto.AspireStartAsync(counter, TimeSpan.FromMinutes(5)); + await VerifyEndpointAsync(auto, counter, "/verify", "PERSISTENCE_VERIFY_OK"); + await auto.AspireStopAsync(counter); + } + + /// + /// Waits for the server resource and verifies that the endpoint returns the expected marker. + /// + /// The terminal automator used to run CLI and shell commands. + /// The prompt sequence counter used to synchronize command completion. + /// The server endpoint path to call. + /// The marker text expected in the endpoint response. + private static async Task VerifyEndpointAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string path, string marker) + { + await auto.TypeAsync("aspire wait server --status up --timeout 300"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5)); + + await auto.TypeAsync("aspire describe server --format json > server.json"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync($"SERVER_URL=$(jq -er '.resources[0].urls[0].url' server.json) && for i in $(seq 1 30); do result=$(curl -ksS \"$SERVER_URL{path}\" 2>/dev/null || true); echo \"$result\"; echo \"$result\" | grep -q '{marker}' && break; sleep 2; done && echo \"$result\" | grep -q '{marker}'"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureRunAsEmulatorModeTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureRunAsEmulatorModeTests.cs new file mode 100644 index 00000000000..5f4e96d0b72 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureRunAsEmulatorModeTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECOSMOSDB001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureRunAsEmulatorModeTests +{ + public static TheoryData> RunAsEmulatorResources => new() + { + { "Azure App Configuration", (builder, configure) => builder.AddAzureAppConfiguration("appconfig").RunAsEmulator(_ => configure()).Resource }, + { "Azure Cosmos DB", (builder, configure) => builder.AddAzureCosmosDB("cosmos").RunAsEmulator(_ => configure()).Resource }, + { "Azure Cosmos DB preview", (builder, configure) => builder.AddAzureCosmosDB("cosmos").RunAsPreviewEmulator(_ => configure()).Resource }, + { "Azure Event Hubs", (builder, configure) => builder.AddAzureEventHubs("eventhubs").RunAsEmulator(_ => configure()).Resource }, + { "Azure Kusto", (builder, configure) => builder.AddAzureKustoCluster("kusto").RunAsEmulator(_ => configure()).Resource }, + { "Azure Service Bus", (builder, configure) => builder.AddAzureServiceBus("servicebus").RunAsEmulator(_ => configure()).Resource }, + { "Azure SignalR", (builder, configure) => builder.AddAzureSignalR("signalr").RunAsEmulator(_ => configure()).Resource }, + { "Azure Storage", (builder, configure) => builder.AddAzureStorage("storage").RunAsEmulator(_ => configure()).Resource }, + }; + + [Theory] + [MemberData(nameof(RunAsEmulatorResources))] + public void RunAsEmulator_InRunMode_ConfiguresLocalContainer(string resourceType, Func addResource) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var callbackInvoked = false; + + var resource = addResource(builder, () => callbackInvoked = true); + + Assert.True(callbackInvoked); + Assert.True(resource.IsEmulator() || resource.IsContainer(), $"{resourceType} should be configured as an emulator or local container in run mode."); + Assert.Contains(builder.Resources, resource => resource.Annotations.OfType().Any()); + } + + [Theory] + [MemberData(nameof(RunAsEmulatorResources))] + public void RunAsEmulator_InPublishMode_DoesNotConfigureLocalContainer(string resourceType, Func addResource) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var callbackInvoked = false; + + var resource = addResource(builder, () => callbackInvoked = true); + + Assert.False(callbackInvoked); + Assert.False(resource.IsEmulator(), $"{resourceType} should not be configured as an emulator in publish mode."); + Assert.False(resource.IsContainer(), $"{resourceType} should not be configured as a local container in publish mode."); + Assert.DoesNotContain(builder.Resources, resource => resource.Annotations.OfType().Any()); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs index 117133236dc..8d7a6b4d758 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs @@ -1,6 +1,8 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; using Aspire.TestUtilities; @@ -283,4 +285,14 @@ public async Task VerifyAzureStorageEmulator_queue_auto_created() Assert.Equal(blobNameAndContent, peekMessage.Value.Body.ToString()); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task AzureStorageEmulator_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddAzureStorage("storage").RunAsEmulator(container => container.WithPersistentLifetime()), + "storage"); + } + } diff --git a/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs b/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs index c1c00f652aa..e74e31043ca 100644 --- a/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs +++ b/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; @@ -249,4 +251,14 @@ await pipeline.ExecuteAsync(async token => } } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Garnet_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddGarnet("resource").WithPersistentLifetime(), + "resource"); + } + } diff --git a/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs b/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs index 058f53fffb6..ef1ee1ab265 100644 --- a/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs +++ b/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; @@ -272,4 +274,15 @@ await pipeline.ExecuteAsync(async token => } } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Kafka_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddKafka("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs b/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs index 08ed522ecbe..5ec2bda789d 100644 --- a/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs +++ b/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; @@ -202,4 +204,15 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Milvus_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddMilvus("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs index a6ca6cd58e2..d6870dad37c 100644 --- a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs +++ b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -457,6 +459,17 @@ private static async Task CreateTestDataAsync(IMongoDatabase mongoDatabase, Canc item => Assert.Contains("Schindler's List", item.Name) ); } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task MongoDB_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddMongoDB("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } } public class Movie diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs index 21b9d8385e2..2ba244fb016 100644 --- a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs @@ -857,4 +857,15 @@ public async Task AddDatabaseCreatesDatabaseWithSpecialNames() Assert.Equal(ConnectionState.Open, conn.State); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task MySql_WithPersistentLifetime_ReusesContainerWithDefaults() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddMySql("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs b/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs index feaabfb06ec..adbba4ee3ad 100644 --- a/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs +++ b/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.Utils; using Microsoft.Extensions.Hosting; @@ -358,4 +360,15 @@ public async Task VerifyWaitForOnNatsBlocksDependentResources() await app.StopAsync(); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Nats_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddNats("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs b/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs index 5162ce86da8..2f2e6617f6c 100644 --- a/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs +++ b/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; @@ -476,4 +478,14 @@ public async Task VerifyWaitForOnOracleBlocksDependentResources() await app.StopAsync(); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Oracle_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddOracle("resource").WithPersistentLifetime(), + "resource"); + } + } diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs index e4ac4040e8a..49a072e5bf1 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -813,4 +813,15 @@ public async Task AddDatabaseCreatesMultipleDatabases() Assert.Equal(ConnectionState.Open, conn.State); } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Postgres_WithPersistentLifetime_ReusesContainerWithDefaults() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddPostgres("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs index 6058aaa7280..6f1f36ab467 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; @@ -287,4 +289,15 @@ public async Task VerifyWaitForOnQdrantBlocksDependentResources() await app.StopAsync(); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Qdrant_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddQdrant("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs index 70807ad03be..c8064a3f42c 100644 --- a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using System.Text; using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; @@ -233,4 +235,15 @@ await channel.BasicPublishAsync( } } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task RabbitMQ_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddRabbitMQ("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 4a0baad26bf..ba1bf1065f6 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using System.Net.Http.Json; using System.Net; using Aspire.TestUtilities; @@ -643,4 +645,15 @@ internal sealed class RedisInsightDatabaseModel public int? Port { get; set; } public string? Name { get; set; } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Redis_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddRedis("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs b/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs index 72708b3cb75..9d6587f4b87 100644 --- a/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs +++ b/tests/Aspire.Hosting.Seq.Tests/SeqFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using System.Text; using System.Text.Json; using Aspire.TestUtilities; @@ -184,4 +186,15 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Seq_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddSeq("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + } diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index 5262a9171ed..6ff299fccec 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using System.Data; using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; @@ -578,4 +580,14 @@ public async Task AddDatabaseCreatesMultipleDatabases() Assert.Equal(ConnectionState.Open, conn.State); } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task SqlServer_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddSqlServer("resource").WithPersistentLifetime(), + "resource"); + } + } diff --git a/tests/Aspire.Hosting.Tests/Utils/PersistentContainerTestHelpers.cs b/tests/Aspire.Hosting.Tests/Utils/PersistentContainerTestHelpers.cs new file mode 100644 index 00000000000..8ee99f333fc --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/PersistentContainerTestHelpers.cs @@ -0,0 +1,110 @@ +// 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.Testing; +using Aspire.Shared.UserSecrets; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Utils; + +public static class PersistentContainerTestHelpers +{ + private const string ContainerIdPropertyName = "container.id"; + private const string ContainerLifetimePropertyName = "container.lifetime"; + + /// + /// Verifies that a resource configured with a persistent lifetime uses the same Docker container across AppHost runs. + /// + /// The xUnit output helper used for test and resource logging. + /// Configures the persistent resource on each AppHost run. + /// The resource name whose persistent Docker container identity should be compared. + /// Whether to apply the test container registry override for integrations that require CI-mirrored images. + /// The timeout for starting, stopping, and observing the resource. Defaults to 10 minutes because some container integrations have slow cold starts. + public static async Task AssertResourceReusesContainerAsync( + ITestOutputHelper testOutputHelper, + Action configureResource, + string resourceName, + bool useTestContainerRegistry = false, + TimeSpan? timeout = null) + { + using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromMinutes(10)); + using var aspireStore = new TestTempDirectory(); + var userSecretsId = Guid.NewGuid().ToString("N"); + + try + { + var before = await RunContainerAsync(); + var after = await RunContainerAsync(); + + Assert.Equal(before, after); + } + finally + { + var userSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId); + if (Path.GetDirectoryName(userSecretsPath) is { } userSecretsDirectory && Directory.Exists(userSecretsDirectory)) + { + try + { + Directory.Delete(userSecretsDirectory, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup only. A locked secrets file should not fail an otherwise successful test. + } + } + } + + async Task RunContainerAsync() + { + var args = new[] + { + "--environment=Development", + $"{KnownConfigNames.AspireUserSecretsId}={userSecretsId}" + }; + + using var builder = (useTestContainerRegistry + ? TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper, args) + : TestDistributedApplicationBuilder.Create(testOutputHelper, args)) + .WithTempAspireStore(aspireStore.Path) + .WithResourceCleanUp(false); + + Assert.True(builder.UserSecretsManager.IsAvailable); + + configureResource(builder); + + using var app = builder.Build(); + await app.StartAsync(cts.Token); + + var resourceNotificationService = app.Services.GetRequiredService(); + var containerIdentity = await GetContainerIdentityAsync(resourceNotificationService, resourceName, cts.Token); + + await app.StopAsync(cts.Token).WaitAsync(cts.Token); + + return containerIdentity; + } + } + + /// + /// Gets the Docker container identity for a persistent resource after it becomes healthy. + /// + private static async Task GetContainerIdentityAsync(ResourceNotificationService resourceNotificationService, string resourceName, CancellationToken cancellationToken) + { + await resourceNotificationService.WaitForResourceHealthyAsync(resourceName, cancellationToken); + var resourceEvent = await resourceNotificationService.WaitForResourceAsync(resourceName, evt => + { + return GetPropertyValue(evt, ContainerLifetimePropertyName) is ContainerLifetime.Persistent && + GetPropertyValue(evt, ContainerIdPropertyName) is string { Length: > 0 }; + }, cancellationToken); + + var containerLifetime = GetPropertyValue(resourceEvent, ContainerLifetimePropertyName); + Assert.Equal(ContainerLifetime.Persistent, containerLifetime); + + var containerId = Assert.IsType(GetPropertyValue(resourceEvent, ContainerIdPropertyName)); + Assert.NotEmpty(containerId); + + return containerId; + } + + private static object? GetPropertyValue(ResourceEvent resourceEvent, string propertyName) => + resourceEvent.Snapshot.Properties.FirstOrDefault(x => x.Name == propertyName)?.Value; +} diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs index 29eabd66bb7..7046259e9a0 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs @@ -51,8 +51,11 @@ public static IDistributedApplicationTestingBuilder Create(Action - Create(o => o.ContainerRegistryOverride = ComponentTestConstants.AspireTestContainerRegistry, testOutputHelper); + public static IDistributedApplicationTestingBuilder CreateWithTestContainerRegistry(ITestOutputHelper testOutputHelper, params string[] args) => + Create( + options => options.ContainerRegistryOverride = ComponentTestConstants.AspireTestContainerRegistry, + testOutputHelper, + args); private static IDistributedApplicationTestingBuilder CreateCore(string[] args, Action? configureOptions, ITestOutputHelper? testOutputHelper = null) { diff --git a/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs b/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs index 5aeb51a001a..c6c2fd0f64c 100644 --- a/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs +++ b/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. + using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; @@ -236,4 +238,15 @@ public async Task VerifyWaitForOnValkeyBlocksDependentResources() await app.StopAsync(); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public Task Valkey_WithPersistentLifetime_ReusesContainer() + { + return PersistentContainerTestHelpers.AssertResourceReusesContainerAsync( + testOutputHelper, + builder => builder.AddValkey("resource").WithPersistentLifetime(), + "resource", + useTestContainerRegistry: true); + } + }