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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal(
bool mountDockerSocket = false,
TemporaryWorkspace? workspace = null,
IEnumerable<string>? additionalVolumes = null,
string? network = null,
int width = 160,
int height = 48,
[CallerMemberName] string testName = "")
Expand Down Expand Up @@ -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}");
Expand Down
170 changes: 170 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/PersistentContainerEndToEndTests.cs
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
danegsta marked this conversation as resolved.
{
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<Projects.{{ProjectName}}_ApiService>("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);
}

/// <summary>
/// Waits for the server resource and verifies that the endpoint returns the expected marker.
/// </summary>
/// <param name="auto">The terminal automator used to run CLI and shell commands.</param>
/// <param name="counter">The prompt sequence counter used to synchronize command completion.</param>
/// <param name="path">The server endpoint path to call.</param>
/// <param name="marker">The marker text expected in the endpoint response.</param>
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));
}
}
53 changes: 53 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureRunAsEmulatorModeTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, Func<IDistributedApplicationBuilder, Action, IResource>> 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<IDistributedApplicationBuilder, Action, IResource> 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<ContainerImageAnnotation>().Any());
}

[Theory]
[MemberData(nameof(RunAsEmulatorResources))]
public void RunAsEmulator_InPublishMode_DoesNotConfigureLocalContainer(string resourceType, Func<IDistributedApplicationBuilder, Action, IResource> 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<ContainerImageAnnotation>().Any());
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
}

}
12 changes: 12 additions & 0 deletions tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
}

}
13 changes: 13 additions & 0 deletions tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}

}
13 changes: 13 additions & 0 deletions tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}

}
13 changes: 13 additions & 0 deletions tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Loading
Loading