From ebaad02d16313c064ed214dbd9551188cec4cc72 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 24 Feb 2025 18:32:12 -0800 Subject: [PATCH 1/8] Ensure containers EH and SB files can be used from non-root containers --- .../AzureEventHubsExtensions.cs | 9 ++++ .../AzureServiceBusExtensions.cs | 9 ++++ .../AzureEventHubsExtensionsTests.cs | 47 +++++++++++++++++++ .../AzureServiceBusExtensionsTests.cs | 14 +++++- ...utedApplicationTestingBuilderExtensions.cs | 6 ++- 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index d22f612ec2..2794bcd11d 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -322,6 +322,15 @@ public static IResourceBuilder RunAsEmulator(this IResou // Deterministic file path for the configuration file based on its content var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); + // The docker container runs as a non-root user, so we need to grant other user's read/write permission + if (!OperatingSystem.IsWindows()) + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(configJsonPath, mode); + } + builder.WithAnnotation(new ContainerMountAnnotation( configJsonPath, AzureEventHubsEmulatorResource.EmulatorConfigJsonPath, diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 9861a74229..fd2428befd 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -423,6 +423,15 @@ public static IResourceBuilder RunAsEmulator(this IReso // Deterministic file path for the configuration file based on its content var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); + // The docker container runs as a non-root user, so we need to grant other user's read/write permission + if (!OperatingSystem.IsWindows()) + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(configJsonPath, mode); + } + builder.WithAnnotation(new ContainerMountAnnotation( configJsonPath, AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 1af15125d2..3577b3df51 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -110,6 +110,41 @@ public async Task VerifyAzureEventHubsEmulatorResource(bool referenceHub) } } + [Fact] + [RequiresDocker] + public async Task AzureEventHubsNs_ProducesAndConsumes() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var eventHubns = builder.AddAzureEventHubs("eventhubns") + .RunAsEmulator(); + var eventHub = eventHubns.AddHub("hub"); + + using var app = builder.Build(); + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration["ConnectionStrings:eventhubns"] = await eventHubns.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + hb.AddAzureEventHubProducerClient("eventhubns", settings => settings.EventHubName = "hub"); + hb.AddAzureEventHubConsumerClient("eventhubns", settings => settings.EventHubName = "hub"); + + using var host = hb.Build(); + await host.StartAsync(); + + var producerClient = host.Services.GetRequiredService(); + var consumerClient = host.Services.GetRequiredService(); + + // If no exception is thrown when awaited, the Event Hubs service has acknowledged + // receipt and assumed responsibility for delivery of the set of events to its partition. + await producerClient.SendAsync([new EventData(Encoding.UTF8.GetBytes("hello worlds"))]); + + await foreach (var partitionEvent in consumerClient.ReadEventsAsync(new ReadEventOptions { MaximumWaitTime = TimeSpan.FromSeconds(5) })) + { + Assert.Equal("hello worlds", Encoding.UTF8.GetString(partitionEvent.Data.EventBody.ToArray())); + break; + } + } + [Fact] public void AzureEventHubsUseEmulatorCallbackWithWithDataBindMountResultsInBindMountAnnotationWithDefaultPath() { @@ -393,6 +428,18 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJsonWithCustomiza var configJsonContent = File.ReadAllText(volumeAnnotation.Source!); + if (!OperatingSystem.IsWindows()) + { + // Ensure the configuration file has correct attributes + var fileInfo = new FileInfo(volumeAnnotation.Source!); + + var expectedUnixFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + Assert.True(fileInfo.UnixFileMode.HasFlag(expectedUnixFileMode)); + } + Assert.Equal(/*json*/""" { "UserConfig": { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 7b59a30128..60c0864538 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -215,7 +215,7 @@ public async Task VerifyWaitForOnServiceBusEmulatorBlocksDependentResources() await app.StopAsync(); } - [Fact(Skip = "Azure ServiceBus emulator is not reliable in CI - https://github.com/dotnet/aspire/issues/7066")] + [Fact] [RequiresDocker] public async Task VerifyAzureServiceBusEmulatorResource() { @@ -470,6 +470,18 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() var serviceBusEmulatorResource = builder.Resources.OfType().Single(x => x is { } serviceBusResource && serviceBusResource.IsEmulator); var volumeAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single(); + if (!OperatingSystem.IsWindows()) + { + // Ensure the configuration file has correct attributes + var fileInfo = new FileInfo(volumeAnnotation.Source!); + + var expectedUnixFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + Assert.True(fileInfo.UnixFileMode.HasFlag(expectedUnixFileMode)); + } + var configJsonContent = File.ReadAllText(volumeAnnotation.Source!); Assert.Equal(/*json*/""" diff --git a/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs b/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs index 2ab4fa0501..918fc20027 100644 --- a/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs +++ b/tests/Shared/DistributedApplicationTestingBuilderExtensions.cs @@ -23,9 +23,13 @@ public static IDistributedApplicationTestingBuilder WithTestAndResourceLogging(t builder.Services.AddLogging(builder => builder.AddFilter("Aspire.Hosting", LogLevel.Trace)); return builder; } + public static IDistributedApplicationTestingBuilder WithTempAspireStore(this IDistributedApplicationTestingBuilder builder) { - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + // We create the Aspire Store in a folder with user-only access. This way non-root containers won't be able + // to access the files unless they correctly assign the required permissions for the container to work. + + builder.Configuration["Aspire:Store:Path"] = Directory.CreateTempSubdirectory().FullName; return builder; } } From adb706d65230d32cee0e62e4c4aec8efd3cfd3da Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 24 Feb 2025 18:43:28 -0800 Subject: [PATCH 2/8] Add HC in test --- .../AzureEventHubsExtensionsTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 3577b3df51..1b3072891e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -114,6 +114,8 @@ public async Task VerifyAzureEventHubsEmulatorResource(bool referenceHub) [RequiresDocker] public async Task AzureEventHubsNs_ProducesAndConsumes() { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); var eventHubns = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator(); @@ -131,12 +133,15 @@ public async Task AzureEventHubsNs_ProducesAndConsumes() using var host = hb.Build(); await host.StartAsync(); + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceHealthyAsync(eventHubns.Resource.Name, cts.Token); + var producerClient = host.Services.GetRequiredService(); var consumerClient = host.Services.GetRequiredService(); // If no exception is thrown when awaited, the Event Hubs service has acknowledged // receipt and assumed responsibility for delivery of the set of events to its partition. - await producerClient.SendAsync([new EventData(Encoding.UTF8.GetBytes("hello worlds"))]); + await producerClient.SendAsync([new EventData(Encoding.UTF8.GetBytes("hello worlds"))], cts.Token); await foreach (var partitionEvent in consumerClient.ReadEventsAsync(new ReadEventOptions { MaximumWaitTime = TimeSpan.FromSeconds(5) })) { From 0cc7553315f7df0bda9a1e4b687ee0fccd700b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Thu, 27 Feb 2025 16:41:02 -0800 Subject: [PATCH 3/8] Optimize required unix file modes (#7781) --- .../PostgresEndToEnd.AppHost.csproj | 1 + .../MySqlDb.AppHost/MySqlDb.AppHost.csproj | 1 + .../AzureEventHubsExtensions.cs | 8 +- .../AzureServiceBusExtensions.cs | 8 +- .../MySqlBuilderExtensions.cs | 81 +++++-- .../PostgresBuilderExtensions.cs | 211 ++++++++++++------ .../SqlServerBuilderExtensions.cs | 20 +- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 43 ++-- .../AzureEventHubsExtensionsTests.cs | 4 +- .../AzureServiceBusExtensionsTests.cs | 4 +- .../GarnetFunctionalTests.cs | 2 +- .../KafkaFunctionalTests.cs | 3 +- .../MilvusFunctionalTests.cs | 2 + .../MongoDbFunctionalTests.cs | 15 +- .../AddMySqlTests.cs | 35 +-- .../MySqlFunctionalTests.cs | 10 +- .../NatsFunctionalTests.cs | 1 + .../AddPostgresTests.cs | 49 +++- .../PostgresFunctionalTests.cs | 3 +- .../QdrantFunctionalTests.cs | 1 + .../RabbitMQFunctionalTests.cs | 1 + .../RedisFunctionalTests.cs | 6 +- .../SqlServerFunctionalTests.cs | 27 +-- .../ValkeyFunctionalTests.cs | 1 + 24 files changed, 336 insertions(+), 201 deletions(-) diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj index ef820feaad..5078d00f52 100644 --- a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj +++ b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj @@ -6,6 +6,7 @@ enable enable true + f8cd0caa-3990-4f6f-889f-3cf66ac52de9 diff --git a/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj b/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj index b8aa4c2b39..c743ac3046 100644 --- a/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj +++ b/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj @@ -6,6 +6,7 @@ enable enable true + 7359b387-0386-4cd0-9f26-7b40bdb8348d diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 2794bcd11d..35aedb8630 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -19,6 +19,8 @@ namespace Aspire.Hosting; /// public static class AzureEventHubsExtensions { + private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + /// /// Adds an Azure Event Hubs Namespace resource to the application model. This resource can be used to create Event Hub resources. /// @@ -325,10 +327,7 @@ public static IResourceBuilder RunAsEmulator(this IResou // The docker container runs as a non-root user, so we need to grant other user's read/write permission if (!OperatingSystem.IsWindows()) { - var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - - File.SetUnixFileMode(configJsonPath, mode); + File.SetUnixFileMode(configJsonPath, FileMode644); } builder.WithAnnotation(new ContainerMountAnnotation( @@ -434,6 +433,7 @@ public static IResourceBuilder WithConfiguration private static string WriteEmulatorConfigJson(AzureEventHubsResource emulatorResource) { + // This temporary file is not used by the container, it will be copied and then deleted var filePath = Path.GetTempFileName(); using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Write); diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index fd2428befd..61baec40cc 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -20,6 +20,8 @@ namespace Aspire.Hosting; /// public static class AzureServiceBusExtensions { + private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + /// /// Adds an Azure Service Bus Namespace resource to the application model. This resource can be used to create queue, topic, and subscription resources. /// @@ -426,10 +428,7 @@ public static IResourceBuilder RunAsEmulator(this IReso // The docker container runs as a non-root user, so we need to grant other user's read/write permission if (!OperatingSystem.IsWindows()) { - var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - - File.SetUnixFileMode(configJsonPath, mode); + File.SetUnixFileMode(configJsonPath, FileMode644); } builder.WithAnnotation(new ContainerMountAnnotation( @@ -559,6 +558,7 @@ public static IResourceBuilder WithHostPort(thi private static string WriteEmulatorConfigJson(AzureServiceBusResource emulatorResource) { + // This temporary file is not used by the container, it will be copied and then deleted var filePath = Path.GetTempFileName(); using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Write); diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs index 6e751dd2fc..26f4bb62c2 100644 --- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs +++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting; public static class MySqlBuilderExtensions { private const string PasswordEnvVarName = "MYSQL_ROOT_PASSWORD"; + private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; /// /// Adds a MySQL server resource to the application model. For local development a container is used. @@ -102,14 +103,11 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui containerName ??= $"{builder.Resource.Name}-phpmyadmin"; - var configurationTempFileName = Path.GetTempFileName(); - var phpMyAdminContainer = new PhpMyAdminContainerResource(containerName); var phpMyAdminContainerBuilder = builder.ApplicationBuilder.AddResource(phpMyAdminContainer) .WithImage(MySqlContainerImageTags.PhpMyAdminImage, MySqlContainerImageTags.PhpMyAdminTag) .WithImageRegistry(MySqlContainerImageTags.Registry) .WithHttpEndpoint(targetPort: 80, name: "http") - .WithBindMount(configurationTempFileName, "/etc/phpmyadmin/config.user.inc.php") .ExcludeFromManifest(); builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => @@ -140,32 +138,33 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui } else { - using var stream = new FileStream(configurationTempFileName, FileMode.Create); - using var writer = new StreamWriter(stream); + var tempConfigFile = WritePhpMyAdminConfiguration(mySqlInstances); - writer.WriteLine("(); + + // Deterministic file path for the configuration file based on its content + var configStoreFilename = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-config.user.inc.php", tempConfigFile); + + // Need to grant read access to the config file on unix like systems. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(configStoreFilename, FileMode644); + } + + phpMyAdminContainerBuilder.WithBindMount(configStoreFilename, "/etc/phpmyadmin/config.user.inc.php"); + } + finally + { + try + { + File.Delete(tempConfigFile); + } + catch { - var endpoint = mySqlInstance.PrimaryEndpoint; - writer.WriteLine("$i++;"); - // PhpMyAdmin assumes MySql is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - writer.WriteLine($"$cfg['Servers'][$i]['host'] = '{endpoint.Resource.Name}:{endpoint.TargetPort}';"); - writer.WriteLine($"$cfg['Servers'][$i]['verbose'] = '{mySqlInstance.Name}';"); - writer.WriteLine($"$cfg['Servers'][$i]['auth_type'] = 'cookie';"); - writer.WriteLine($"$cfg['Servers'][$i]['user'] = 'root';"); - writer.WriteLine($"$cfg['Servers'][$i]['password'] = '{mySqlInstance.PasswordParameter.Value}';"); - writer.WriteLine($"$cfg['Servers'][$i]['AllowNoPassword'] = true;"); - writer.WriteLine(); } } - writer.WriteLine("$cfg['DefaultServer'] = 1;"); - writer.WriteLine("?>"); } return Task.CompletedTask; @@ -235,4 +234,38 @@ public static IResourceBuilder WithInitBindMount(this IReso return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly); } + + private static string WritePhpMyAdminConfiguration(IEnumerable mySqlInstances) + { + // This temporary file is not used by the container, it will be copied and then deleted + var filePath = Path.GetTempFileName(); + + using var writer = new StreamWriter(filePath); + + writer.WriteLine(""); + + return filePath; + } } diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index e7af6de482..fcd48c8242 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Hashing; using System.Text; using System.Text.Json; using Aspire.Hosting.ApplicationModel; @@ -17,6 +18,11 @@ public static class PostgresBuilderExtensions { private const string UserEnvVarName = "POSTGRES_USER"; private const string PasswordEnvVarName = "POSTGRES_PASSWORD"; + private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + private const UnixFileMode FileMode755 = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; /// /// Adds a PostgreSQL resource to the application model. A container is used for local development. @@ -150,56 +156,45 @@ public static IResourceBuilder WithPgAdmin(this IResourceBuilder builde .WithImageRegistry(PostgresContainerImageTags.PgAdminRegistry) .WithHttpEndpoint(targetPort: 80, name: "http") .WithEnvironment(SetPgAdminEnvironmentVariables) - .WithBindMount(Path.GetTempFileName(), "/pgadmin4/servers.json") .WithHttpHealthCheck("/browser") .ExcludeFromManifest(); builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => { - var serverFileMount = pgAdminContainer.Annotations.OfType().Single(v => v.Target == "/pgadmin4/servers.json"); + // Add the servers.json file bind mount to the pgAdmin container + var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); - var serverFileBuilder = new StringBuilder(); + // Create servers.json file content in a temporary file + + var tempConfigFile = WritePgAdminServerJson(postgresInstances); - using var stream = new FileStream(serverFileMount.Source!, FileMode.Create); - using var writer = new Utf8JsonWriter(stream); - // Need to grant read access to the config file on unix like systems. - if (!OperatingSystem.IsWindows()) + try { - File.SetUnixFileMode(serverFileMount.Source!, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); - } + var aspireStore = e.Services.GetRequiredService(); - var serverIndex = 1; + // Deterministic file path for the configuration file based on its content + var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-servers.json", tempConfigFile); - writer.WriteStartObject(); - writer.WriteStartObject("Servers"); + // Need to grant read access to the config file on unix like systems. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(configJsonPath, FileMode644); + } - foreach (var postgresInstance in postgresInstances) + pgAdminContainerBuilder.WithBindMount(configJsonPath, "/pgadmin4/servers.json"); + } + finally { - if (postgresInstance.PrimaryEndpoint.IsAllocated) + try + { + File.Delete(tempConfigFile); + } + catch { - var endpoint = postgresInstance.PrimaryEndpoint; - - writer.WriteStartObject($"{serverIndex}"); - writer.WriteString("Name", postgresInstance.Name); - writer.WriteString("Group", "Servers"); - // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - writer.WriteString("Host", endpoint.Resource.Name); - writer.WriteNumber("Port", (int)endpoint.TargetPort!); - writer.WriteString("Username", postgresInstance.UserNameParameter?.Value ?? "postgres"); - writer.WriteString("SSLMode", "prefer"); - writer.WriteString("MaintenanceDB", "postgres"); - writer.WriteString("PasswordExecCommand", $"echo '{postgresInstance.PasswordParameter.Value}'"); // HACK: Generating a pass file and playing around with chmod is too painful. - writer.WriteEndObject(); } - - serverIndex++; } - writer.WriteEndObject(); - writer.WriteEndObject(); - return Task.CompletedTask; }); @@ -277,13 +272,11 @@ public static IResourceBuilder WithPgWeb(this IResourceB else { containerName ??= $"{builder.Resource.Name}-pgweb"; - var dir = Directory.CreateTempSubdirectory().FullName; var pgwebContainer = new PgWebContainerResource(containerName); var pgwebContainerBuilder = builder.ApplicationBuilder.AddResource(pgwebContainer) .WithImage(PostgresContainerImageTags.PgWebImage, PostgresContainerImageTags.PgWebTag) .WithImageRegistry(PostgresContainerImageTags.PgWebRegistry) .WithHttpEndpoint(targetPort: 8081, name: "http") - .WithBindMount(dir, "/.pgweb/bookmarks") .WithArgs("--bookmarks-dir=/.pgweb/bookmarks") .WithArgs("--sessions") .ExcludeFromManifest(); @@ -294,44 +287,60 @@ public static IResourceBuilder WithPgWeb(this IResourceB pgwebContainerBuilder.WithHttpHealthCheck(); - builder.ApplicationBuilder.Eventing.Subscribe(async (e, ct) => + builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => { - var adminResource = builder.ApplicationBuilder.Resources.OfType().Single(); - var serverFileMount = adminResource.Annotations.OfType().Single(v => v.Target == "/.pgweb/bookmarks"); + // Add the bookmarks to the pgweb container + + // Create a folder using IAspireStore. Its name is deterministic, based on all the database resources + // such that the same folder is reused across persistent usages, and changes in configuration require + // new folders. + var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); - if (!Directory.Exists(serverFileMount.Source!)) - { - Directory.CreateDirectory(serverFileMount.Source!); - } + var aspireStore = e.Services.GetRequiredService(); + + var tempDir = WritePgWebBookmarks(postgresInstances, out var contentHash); - if (!OperatingSystem.IsWindows()) + // Create a deterministic folder name based on the content hash such that the same folder is reused across + // persistent usages. + var pgwebBookmarks = Path.Combine(aspireStore.BasePath, $"{pgwebContainer.Name}.{Convert.ToHexString(contentHash)[..12].ToLowerInvariant()}"); + + try { - var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + Directory.CreateDirectory(pgwebBookmarks); - File.SetUnixFileMode(serverFileMount.Source!, mode); - } + // Grant listing access to the bookmarks folder on unix like systems. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(pgwebBookmarks, FileMode755); + } + + foreach (var file in Directory.GetFiles(tempDir)) + { + // Target is overwritten just in case the previous attempts has failed + var destinationPath = Path.Combine(pgwebBookmarks, Path.GetFileName(file)); + File.Copy(file, destinationPath, overwrite: true); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(destinationPath, FileMode644); + } + } - foreach (var postgresDatabase in postgresInstances) + pgwebContainerBuilder.WithBindMount(pgwebBookmarks, "/.pgweb/bookmarks"); + } + finally { - var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; - - // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - var fileContent = $""" - host = "{postgresDatabase.Parent.Name}" - port = {postgresDatabase.Parent.PrimaryEndpoint.TargetPort} - user = "{user}" - password = "{postgresDatabase.Parent.PasswordParameter.Value}" - database = "{postgresDatabase.DatabaseName}" - sslmode = "disable" - """; - - var filePath = Path.Combine(serverFileMount.Source!, $"{postgresDatabase.Name}.toml"); - await File.WriteAllTextAsync(filePath, fileContent, ct).ConfigureAwait(false); + try + { + Directory.Delete(tempDir, true); + } + catch + { + } } + + return Task.CompletedTask; }); return builder; @@ -403,4 +412,78 @@ public static IResourceBuilder WithInitBindMount(this IR return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly); } + + private static string WritePgWebBookmarks(IEnumerable postgresInstances, out byte[] contentHash) + { + var dir = Directory.CreateTempSubdirectory().FullName; + + // Fast, non-cryptographic hash. + var hash = new XxHash3(); + + foreach (var postgresDatabase in postgresInstances) + { + var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; + + // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address + // This will need to be refactored once updated service discovery APIs are available + var fileContent = $""" + host = "{postgresDatabase.Parent.Name}" + port = {postgresDatabase.Parent.PrimaryEndpoint.TargetPort} + user = "{user}" + password = "{postgresDatabase.Parent.PasswordParameter.Value}" + database = "{postgresDatabase.DatabaseName}" + sslmode = "disable" + """; + + hash.Append(Encoding.UTF8.GetBytes(fileContent)); + + File.WriteAllText(Path.Combine(dir, $"{postgresDatabase.Name}.toml"), fileContent); + } + + contentHash = hash.GetCurrentHash(); + + return dir; + } + + private static string WritePgAdminServerJson(IEnumerable postgresInstances) + { + // This temporary file is not used by the container, it will be copied and then deleted + var filePath = Path.GetTempFileName(); + + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Write); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + writer.WriteStartObject("Servers"); + + var serverIndex = 1; + + foreach (var postgresInstance in postgresInstances) + { + if (postgresInstance.PrimaryEndpoint.IsAllocated) + { + var endpoint = postgresInstance.PrimaryEndpoint; + + writer.WriteStartObject($"{serverIndex}"); + writer.WriteString("Name", postgresInstance.Name); + writer.WriteString("Group", "Servers"); + // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address + // This will need to be refactored once updated service discovery APIs are available + writer.WriteString("Host", endpoint.Resource.Name); + writer.WriteNumber("Port", (int)endpoint.TargetPort!); + writer.WriteString("Username", postgresInstance.UserNameParameter?.Value ?? "postgres"); + writer.WriteString("SSLMode", "prefer"); + writer.WriteString("MaintenanceDB", "postgres"); + writer.WriteString("PasswordExecCommand", $"echo '{postgresInstance.PasswordParameter.Value}'"); // HACK: Generating a pass file and playing around with chmod is too painful. + writer.WriteEndObject(); + } + + serverIndex++; + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + + return filePath; + } } diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 4674604a80..5d7d2bb1ce 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -101,25 +101,15 @@ public static IResourceBuilder WithDataVolume(this IRes /// The source directory on the host to mount into the container. /// A flag that indicates if this is a read-only mount. /// The . + /// + /// The container starts up as non-root and the directory must be readable by the user that the container runs as. + /// https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver16&pivots=cs1-bash#mount-a-host-directory-as-data-volume + /// public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(source); - // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver15&pivots=cs1-bash#mount-a-host-directory-as-data-volume - - foreach (var dir in new string[] { "data", "log", "secrets" }) - { - var path = Path.Combine(source, dir); - - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } - - builder.WithBindMount(path, $"/var/opt/mssql/{dir}", isReadOnly); - } - - return builder; + return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); } } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 9a6e290394..8fd21b01cd 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1080,24 +1080,6 @@ private void PrepareContainers() ctr.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, containerObjectInstance.Suffix); SetInitialResourceState(container, ctr); - if (container.TryGetContainerMounts(out var containerMounts)) - { - ctr.Spec.VolumeMounts = []; - - foreach (var mount in containerMounts) - { - var volumeSpec = new VolumeMount - { - Source = mount.Source, - Target = mount.Target, - Type = mount.Type == ContainerMountType.BindMount ? VolumeMountType.Bind : VolumeMountType.Volume, - IsReadOnly = mount.IsReadOnly - }; - - ctr.Spec.VolumeMounts.Add(volumeSpec); - } - } - ctr.Spec.Networks = new List { new ContainerNetworkConnection @@ -1206,6 +1188,8 @@ private async Task CreateContainerAsync(AppResource cr, ILogger resourceLogger, spec.Ports = BuildContainerPorts(cr); } + spec.VolumeMounts = BuildContainerMounts(modelContainerResource); + (spec.RunArgs, var failedToApplyRunArgs) = await BuildRunArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); (var args, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); @@ -1646,4 +1630,27 @@ private static List BuildContainerPorts(AppResource cr) return ports; } + + private static List BuildContainerMounts(IResource container) + { + var volumeMounts = new List(); + + if (container.TryGetContainerMounts(out var containerMounts)) + { + foreach (var mount in containerMounts) + { + var volumeSpec = new VolumeMount + { + Source = mount.Source, + Target = mount.Target, + Type = mount.Type == ContainerMountType.BindMount ? VolumeMountType.Bind : VolumeMountType.Volume, + IsReadOnly = mount.IsReadOnly + }; + + volumeMounts.Add(volumeSpec); + } + } + + return volumeMounts; + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 1b3072891e..3ccc93360b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -438,9 +438,7 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJsonWithCustomiza // Ensure the configuration file has correct attributes var fileInfo = new FileInfo(volumeAnnotation.Source!); - var expectedUnixFileMode = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + var expectedUnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; Assert.True(fileInfo.UnixFileMode.HasFlag(expectedUnixFileMode)); } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 60c0864538..b8d2ba36c4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -475,9 +475,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() // Ensure the configuration file has correct attributes var fileInfo = new FileInfo(volumeAnnotation.Source!); - var expectedUnixFileMode = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + var expectedUnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; Assert.True(fileInfo.UnixFileMode.HasFlag(expectedUnixFileMode)); } diff --git a/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs b/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs index 993686b4cc..dfeb49778a 100644 --- a/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs +++ b/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs @@ -131,7 +131,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) if (!OperatingSystem.IsWindows()) { - // the docker container runs as a non-root user, so we need to grant other user's read/write permission + // The docker container runs as a non-root user, so we need to grant other user's read/write permission // to the bind mount directory. // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. const UnixFileMode BindMountPermissions = diff --git a/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs b/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs index d278dcd79b..9f13d878a2 100644 --- a/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs +++ b/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs @@ -142,7 +142,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) if (!OperatingSystem.IsWindows()) { - // the docker container runs as a non-root user, so we need to grant other user's read/write permission + // The docker container runs as a non-root user, so we need to grant other user's read/write permission // to the bind mount directory. // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. const UnixFileMode BindMountPermissions = @@ -152,6 +152,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) File.SetUnixFileMode(bindMountPath, BindMountPermissions); } + kafka1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs b/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs index 9de82b41b8..39b4d09a8c 100644 --- a/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs +++ b/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs @@ -93,7 +93,9 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } else { + // Milvus container runs as root and will create the directory. bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + milvus1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs index 1ee1f047d2..263ae4feb8 100644 --- a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs +++ b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs @@ -134,7 +134,9 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } else { + // MongoDB container runs as root and will create the directory. bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + mongodb1.WithDataBindMount(bindMountPath); } @@ -258,18 +260,7 @@ public async Task VerifyWithInitBindMount() .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) .Build(); - var bindMountPath = Directory.CreateTempSubdirectory().FullName; - - if (!OperatingSystem.IsWindows()) - { - // Change permissions for non-root accounts (container user account) - const UnixFileMode OwnershipPermissions = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - - File.SetUnixFileMode(bindMountPath, OwnershipPermissions); - } + var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try { diff --git a/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs b/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs index ca2626bec3..31744a625f 100644 --- a/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs @@ -245,28 +245,22 @@ public async Task SingleMySqlInstanceProducesCorrectMySqlHostsVariable() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(myAdmin, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + var container = builder.Resources.Single(r => r.Name == "mySql-phpmyadmin"); + Assert.Empty(container.Annotations.OfType()); + Assert.Equal($"{mysql.Resource.Name}:{mysql.Resource.PrimaryEndpoint.TargetPort}", config["PMA_HOST"]); Assert.NotNull(config["PMA_USER"]); Assert.NotNull(config["PMA_PASSWORD"]); } - [Fact] - public void WithPhpMyAdminAddsContainer() - { - using var builder = TestDistributedApplicationBuilder.Create(); - builder.AddMySql("mySql").WithPhpMyAdmin(); - - var container = builder.Resources.Single(r => r.Name == "mySql-phpmyadmin"); - var volume = container.Annotations.OfType().Single(); - - Assert.True(File.Exists(volume.Source)); // File should exist, but will be empty. - Assert.Equal("/etc/phpmyadmin/config.user.inc.php", volume.Target); - } - [Fact] public void WithPhpMyAdminProducesValidServerConfigFile() { var builder = DistributedApplication.CreateBuilder(); + + var tempStorePath = Directory.CreateTempSubdirectory().FullName; + builder.Configuration["Aspire:Store:Path"] = tempStorePath; + var mysql1 = builder.AddMySql("mysql1").WithPhpMyAdmin(c => c.WithHostPort(8081)); var mysql2 = builder.AddMySql("mysql2").WithPhpMyAdmin(c => c.WithHostPort(8081)); @@ -274,14 +268,14 @@ public void WithPhpMyAdminProducesValidServerConfigFile() mysql1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); mysql2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host3")); - var myAdmin = builder.Resources.Single(r => r.Name.EndsWith("-phpmyadmin")); - var volume = myAdmin.Annotations.OfType().Single(); - using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + var myAdmin = builder.Resources.Single(r => r.Name.EndsWith("-phpmyadmin")); + var volume = myAdmin.Annotations.OfType().Single(); + using var stream = File.OpenRead(volume.Source!); var fileContents = new StreamReader(stream).ReadToEnd(); @@ -292,6 +286,15 @@ public void WithPhpMyAdminProducesValidServerConfigFile() Assert.True(match1.Success); Match match2 = Regex.Match(fileContents, pattern2); Assert.True(match2.Success); + + try + { + Directory.Delete(tempStorePath, true); + } + catch + { + // Ignore. + } } [Fact] diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs index 150887726d..8eba92ca0d 100644 --- a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs @@ -146,6 +146,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; + mysql1.WithDataBindMount(bindMountPath); } @@ -303,15 +304,10 @@ public async Task VerifyWithInitBindMount() var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(bindMountPath); + try { - if (Directory.Exists(bindMountPath)) - { - Directory.Delete(bindMountPath); - } - - Directory.CreateDirectory(bindMountPath); - File.WriteAllText(Path.Combine(bindMountPath, "init.sql"), """ CREATE TABLE cars (brand VARCHAR(255)); INSERT INTO cars (brand) VALUES ('BatMobile'); diff --git a/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs b/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs index 19b0df4ab9..23c1800652 100644 --- a/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs +++ b/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs @@ -175,6 +175,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; + nats1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs index 06197cdf36..81b68437b3 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs @@ -369,12 +369,17 @@ public async Task VerifyManifestWithParameters() } [Fact] - public void WithPgAdminAddsContainer() + public async Task WithPgAdminAddsContainer() { - using var builder = TestDistributedApplicationBuilder.Create(); builder.AddPostgres("mypostgres").WithPgAdmin(pga => pga.WithHostPort(8081)); + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // The mount annotation is added in the AfterEndpointsAllocatedEvent. + await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + var container = builder.Resources.Single(r => r.Name == "mypostgres-pgadmin"); var volume = container.Annotations.OfType().Single(); @@ -443,13 +448,17 @@ public void WithPostgresTwiceEndsUpWithOneContainer() builder.AddPostgres("mypostgres1").WithPgAdmin(pga => pga.WithHostPort(8081)); builder.AddPostgres("mypostgres2").WithPgAdmin(pga => pga.WithHostPort(8081)); - builder.Resources.Single(r => r.Name.EndsWith("-pgadmin")); + Assert.Single(builder.Resources.Where(r => r.Name.EndsWith("-pgadmin"))); } [Fact] public async Task WithPostgresProducesValidServersJsonFile() { var builder = DistributedApplication.CreateBuilder(); + + var tempStorePath = Directory.CreateTempSubdirectory().FullName; + builder.Configuration["Aspire:Store:Path"] = tempStorePath; + var username = builder.AddParameter("pg-user", "myuser"); var pg1 = builder.AddPostgres("mypostgres1").WithPgAdmin(pga => pga.WithHostPort(8081)); var pg2 = builder.AddPostgres("mypostgres2", username).WithPgAdmin(pga => pga.WithHostPort(8081)); @@ -458,13 +467,13 @@ public async Task WithPostgresProducesValidServersJsonFile() pg1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); pg2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host2")); - var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgadmin")); - var volume = pgadmin.Annotations.OfType().Single(); - using var app = builder.Build(); await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgadmin")); + var volume = pgadmin.Annotations.OfType().Single(); + using var stream = File.OpenRead(volume.Source!); var document = JsonDocument.Parse(stream); @@ -489,12 +498,25 @@ public async Task WithPostgresProducesValidServersJsonFile() Assert.Equal("prefer", servers.GetProperty("2").GetProperty("SSLMode").GetString()); Assert.Equal("postgres", servers.GetProperty("2").GetProperty("MaintenanceDB").GetString()); Assert.Equal($"echo '{pg2.Resource.PasswordParameter.Value}'", servers.GetProperty("2").GetProperty("PasswordExecCommand").GetString()); + + try + { + Directory.Delete(tempStorePath, true); + } + catch + { + // Ignore. + } } [Fact] public async Task WithPgwebProducesValidBookmarkFiles() { var builder = DistributedApplication.CreateBuilder(); + + var tempStorePath = Directory.CreateTempSubdirectory().FullName; + builder.Configuration["Aspire:Store:Path"] = tempStorePath; + var pg1 = builder.AddPostgres("mypostgres1").WithPgWeb(pga => pga.WithHostPort(8081)); var pg2 = builder.AddPostgres("mypostgres2").WithPgWeb(pga => pga.WithHostPort(8081)); @@ -505,14 +527,14 @@ public async Task WithPgwebProducesValidBookmarkFiles() var db1 = pg1.AddDatabase("db1"); var db2 = pg2.AddDatabase("db2"); - var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgweb")); - var volume = pgadmin.Annotations.OfType().Single(); - using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgweb")); + var volume = pgadmin.Annotations.OfType().Single(); + var bookMarkFiles = Directory.GetFiles(volume.Source!).OrderBy(f => f).ToArray(); Assert.Collection(bookMarkFiles, @@ -542,6 +564,15 @@ public async Task WithPgwebProducesValidBookmarkFiles() { Assert.Equal(CreatePgWebBookmarkfileContent(db2.Resource), content); }); + + try + { + Directory.Delete(tempStorePath, true); + } + catch + { + // Ignore. + } } [Fact] diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs index e8e2c85cd8..a4e6726411 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -221,7 +221,8 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } else { - bindMountPath = Directory.CreateTempSubdirectory().FullName; + bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + postgres1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs index 79a73a8bd3..11ef25ac23 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs @@ -115,6 +115,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + qdrant1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs index 5450c92d58..b30f9b3013 100644 --- a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs @@ -120,6 +120,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; + rabbitMQ1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index fd83a9b06b..7d0c141a0e 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -380,10 +380,7 @@ public async Task WithDataBindMountShouldPersistStateBetweenUsages() { var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - if (!Directory.Exists(bindMountPath)) - { - Directory.CreateDirectory(bindMountPath); - } + Directory.CreateDirectory(bindMountPath); // Use a bind mount to do a snapshot save @@ -567,6 +564,7 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + redisInsightBuilder1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index e24fc32478..8427d31d72 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -119,7 +119,6 @@ await pipeline.ExecuteAsync(async token => [RequiresDocker] public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) { - string? volumeName = null; string? bindMountPath = null; @@ -149,25 +148,23 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(bindMountPath); - sqlserver1.WithDataBindMount(bindMountPath); + Directory.CreateDirectory(bindMountPath); if (!OperatingSystem.IsWindows()) { - // Change permissions for non-root accounts (container user account) - // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-security?view=sql-server-ver16#storagepermissions - - // This is the minimal set to get the tests passing. - const UnixFileMode MsSqlPermissions = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - - File.SetUnixFileMode(bindMountPath, MsSqlPermissions); - File.SetUnixFileMode($"{bindMountPath}/data", MsSqlPermissions); - File.SetUnixFileMode($"{bindMountPath}/log", MsSqlPermissions); - File.SetUnixFileMode($"{bindMountPath}/secrets", MsSqlPermissions); + // The docker container runs as a non-root user, so we need to grant other user's read/write permission + // to the bind mount directory. + // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. + const UnixFileMode BindMountPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(bindMountPath, BindMountPermissions); } + + sqlserver1.WithDataBindMount(bindMountPath); } using var app1 = builder1.Build(); diff --git a/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs b/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs index f6e89c8ab4..bede5a1047 100644 --- a/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs +++ b/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs @@ -81,6 +81,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + valkey1.WithDataBindMount(bindMountPath); } From 77185547754b9095627b960efd9dff8b58f75ca3 Mon Sep 17 00:00:00 2001 From: William Godbe Date: Fri, 28 Feb 2025 20:56:38 -0800 Subject: [PATCH 4/8] Update branding to 9.1.1-servicing --- eng/Versions.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index e41f7dd83f..2278650564 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -3,9 +3,9 @@ 9 1 - 0 + 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) - preview.1 + servicing net8.0 $(DefaultTargetFramework);net9.0 From aa2541f91edd628af64808386be809bbc3812cf2 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 3 Mar 2025 17:28:50 -0800 Subject: [PATCH 5/8] Targetted backport --- .../PostgresEndToEnd.AppHost.csproj | 1 - .../MySqlDb.AppHost/MySqlDb.AppHost.csproj | 1 - .../MySqlBuilderExtensions.cs | 81 ++----- .../PostgresBuilderExtensions.cs | 213 ++++++------------ src/Aspire.Hosting/Dcp/DcpExecutor.cs | 43 ++-- .../GarnetFunctionalTests.cs | 2 +- .../KafkaFunctionalTests.cs | 3 +- .../MilvusFunctionalTests.cs | 2 - .../MongoDbFunctionalTests.cs | 15 +- .../AddMySqlTests.cs | 35 ++- .../MySqlFunctionalTests.cs | 10 +- .../NatsFunctionalTests.cs | 1 - .../AddPostgresTests.cs | 49 +--- .../PostgresFunctionalTests.cs | 3 +- .../QdrantFunctionalTests.cs | 1 - .../RabbitMQFunctionalTests.cs | 1 - .../RedisFunctionalTests.cs | 6 +- .../SqlServerFunctionalTests.cs | 27 ++- .../ValkeyFunctionalTests.cs | 1 - 19 files changed, 173 insertions(+), 322 deletions(-) diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj index 5078d00f52..ef820feaad 100644 --- a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj +++ b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj @@ -6,7 +6,6 @@ enable enable true - f8cd0caa-3990-4f6f-889f-3cf66ac52de9 diff --git a/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj b/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj index c743ac3046..b8aa4c2b39 100644 --- a/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj +++ b/playground/mysql/MySqlDb.AppHost/MySqlDb.AppHost.csproj @@ -6,7 +6,6 @@ enable enable true - 7359b387-0386-4cd0-9f26-7b40bdb8348d diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs index 26f4bb62c2..6e751dd2fc 100644 --- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs +++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs @@ -14,7 +14,6 @@ namespace Aspire.Hosting; public static class MySqlBuilderExtensions { private const string PasswordEnvVarName = "MYSQL_ROOT_PASSWORD"; - private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; /// /// Adds a MySQL server resource to the application model. For local development a container is used. @@ -103,11 +102,14 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui containerName ??= $"{builder.Resource.Name}-phpmyadmin"; + var configurationTempFileName = Path.GetTempFileName(); + var phpMyAdminContainer = new PhpMyAdminContainerResource(containerName); var phpMyAdminContainerBuilder = builder.ApplicationBuilder.AddResource(phpMyAdminContainer) .WithImage(MySqlContainerImageTags.PhpMyAdminImage, MySqlContainerImageTags.PhpMyAdminTag) .WithImageRegistry(MySqlContainerImageTags.Registry) .WithHttpEndpoint(targetPort: 80, name: "http") + .WithBindMount(configurationTempFileName, "/etc/phpmyadmin/config.user.inc.php") .ExcludeFromManifest(); builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => @@ -138,33 +140,32 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui } else { - var tempConfigFile = WritePhpMyAdminConfiguration(mySqlInstances); - - try - { - var aspireStore = e.Services.GetRequiredService(); - - // Deterministic file path for the configuration file based on its content - var configStoreFilename = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-config.user.inc.php", tempConfigFile); - - // Need to grant read access to the config file on unix like systems. - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(configStoreFilename, FileMode644); - } + using var stream = new FileStream(configurationTempFileName, FileMode.Create); + using var writer = new StreamWriter(stream); - phpMyAdminContainerBuilder.WithBindMount(configStoreFilename, "/etc/phpmyadmin/config.user.inc.php"); - } - finally + writer.WriteLine(""); } return Task.CompletedTask; @@ -234,38 +235,4 @@ public static IResourceBuilder WithInitBindMount(this IReso return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly); } - - private static string WritePhpMyAdminConfiguration(IEnumerable mySqlInstances) - { - // This temporary file is not used by the container, it will be copied and then deleted - var filePath = Path.GetTempFileName(); - - using var writer = new StreamWriter(filePath); - - writer.WriteLine(""); - - return filePath; - } } diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index fcd48c8242..5b9e84ddf7 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Hashing; using System.Text; using System.Text.Json; using Aspire.Hosting.ApplicationModel; @@ -18,12 +17,7 @@ public static class PostgresBuilderExtensions { private const string UserEnvVarName = "POSTGRES_USER"; private const string PasswordEnvVarName = "POSTGRES_PASSWORD"; - private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; - private const UnixFileMode FileMode755 = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherExecute; - + /// /// Adds a PostgreSQL resource to the application model. A container is used for local development. /// @@ -156,45 +150,56 @@ public static IResourceBuilder WithPgAdmin(this IResourceBuilder builde .WithImageRegistry(PostgresContainerImageTags.PgAdminRegistry) .WithHttpEndpoint(targetPort: 80, name: "http") .WithEnvironment(SetPgAdminEnvironmentVariables) + .WithBindMount(Path.GetTempFileName(), "/pgadmin4/servers.json") .WithHttpHealthCheck("/browser") .ExcludeFromManifest(); builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => { - // Add the servers.json file bind mount to the pgAdmin container - + var serverFileMount = pgAdminContainer.Annotations.OfType().Single(v => v.Target == "/pgadmin4/servers.json"); var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); - // Create servers.json file content in a temporary file + var serverFileBuilder = new StringBuilder(); - var tempConfigFile = WritePgAdminServerJson(postgresInstances); - - try + using var stream = new FileStream(serverFileMount.Source!, FileMode.Create); + using var writer = new Utf8JsonWriter(stream); + // Need to grant read access to the config file on unix like systems. + if (!OperatingSystem.IsWindows()) { - var aspireStore = e.Services.GetRequiredService(); + File.SetUnixFileMode(serverFileMount.Source!, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + } - // Deterministic file path for the configuration file based on its content - var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-servers.json", tempConfigFile); + var serverIndex = 1; - // Need to grant read access to the config file on unix like systems. - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(configJsonPath, FileMode644); - } + writer.WriteStartObject(); + writer.WriteStartObject("Servers"); - pgAdminContainerBuilder.WithBindMount(configJsonPath, "/pgadmin4/servers.json"); - } - finally + foreach (var postgresInstance in postgresInstances) { - try - { - File.Delete(tempConfigFile); - } - catch + if (postgresInstance.PrimaryEndpoint.IsAllocated) { + var endpoint = postgresInstance.PrimaryEndpoint; + + writer.WriteStartObject($"{serverIndex}"); + writer.WriteString("Name", postgresInstance.Name); + writer.WriteString("Group", "Servers"); + // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address + // This will need to be refactored once updated service discovery APIs are available + writer.WriteString("Host", endpoint.Resource.Name); + writer.WriteNumber("Port", (int)endpoint.TargetPort!); + writer.WriteString("Username", postgresInstance.UserNameParameter?.Value ?? "postgres"); + writer.WriteString("SSLMode", "prefer"); + writer.WriteString("MaintenanceDB", "postgres"); + writer.WriteString("PasswordExecCommand", $"echo '{postgresInstance.PasswordParameter.Value}'"); // HACK: Generating a pass file and playing around with chmod is too painful. + writer.WriteEndObject(); } + + serverIndex++; } + writer.WriteEndObject(); + writer.WriteEndObject(); + return Task.CompletedTask; }); @@ -272,11 +277,13 @@ public static IResourceBuilder WithPgWeb(this IResourceB else { containerName ??= $"{builder.Resource.Name}-pgweb"; + var dir = Directory.CreateTempSubdirectory().FullName; var pgwebContainer = new PgWebContainerResource(containerName); var pgwebContainerBuilder = builder.ApplicationBuilder.AddResource(pgwebContainer) .WithImage(PostgresContainerImageTags.PgWebImage, PostgresContainerImageTags.PgWebTag) .WithImageRegistry(PostgresContainerImageTags.PgWebRegistry) .WithHttpEndpoint(targetPort: 8081, name: "http") + .WithBindMount(dir, "/.pgweb/bookmarks") .WithArgs("--bookmarks-dir=/.pgweb/bookmarks") .WithArgs("--sessions") .ExcludeFromManifest(); @@ -287,60 +294,44 @@ public static IResourceBuilder WithPgWeb(this IResourceB pgwebContainerBuilder.WithHttpHealthCheck(); - builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => + builder.ApplicationBuilder.Eventing.Subscribe(async (e, ct) => { - // Add the bookmarks to the pgweb container - - // Create a folder using IAspireStore. Its name is deterministic, based on all the database resources - // such that the same folder is reused across persistent usages, and changes in configuration require - // new folders. - + var adminResource = builder.ApplicationBuilder.Resources.OfType().Single(); + var serverFileMount = adminResource.Annotations.OfType().Single(v => v.Target == "/.pgweb/bookmarks"); var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); - var aspireStore = e.Services.GetRequiredService(); - - var tempDir = WritePgWebBookmarks(postgresInstances, out var contentHash); - - // Create a deterministic folder name based on the content hash such that the same folder is reused across - // persistent usages. - var pgwebBookmarks = Path.Combine(aspireStore.BasePath, $"{pgwebContainer.Name}.{Convert.ToHexString(contentHash)[..12].ToLowerInvariant()}"); - - try + if (!Directory.Exists(serverFileMount.Source!)) { - Directory.CreateDirectory(pgwebBookmarks); - - // Grant listing access to the bookmarks folder on unix like systems. - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(pgwebBookmarks, FileMode755); - } + Directory.CreateDirectory(serverFileMount.Source!); + } - foreach (var file in Directory.GetFiles(tempDir)) - { - // Target is overwritten just in case the previous attempts has failed - var destinationPath = Path.Combine(pgwebBookmarks, Path.GetFileName(file)); - File.Copy(file, destinationPath, overwrite: true); - - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(destinationPath, FileMode644); - } - } + if (!OperatingSystem.IsWindows()) + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; - pgwebContainerBuilder.WithBindMount(pgwebBookmarks, "/.pgweb/bookmarks"); + File.SetUnixFileMode(serverFileMount.Source!, mode); } - finally + + foreach (var postgresDatabase in postgresInstances) { - try - { - Directory.Delete(tempDir, true); - } - catch - { - } + var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; + + // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address + // This will need to be refactored once updated service discovery APIs are available + var fileContent = $""" + host = "{postgresDatabase.Parent.Name}" + port = {postgresDatabase.Parent.PrimaryEndpoint.TargetPort} + user = "{user}" + password = "{postgresDatabase.Parent.PasswordParameter.Value}" + database = "{postgresDatabase.DatabaseName}" + sslmode = "disable" + """; + + var filePath = Path.Combine(serverFileMount.Source!, $"{postgresDatabase.Name}.toml"); + await File.WriteAllTextAsync(filePath, fileContent, ct).ConfigureAwait(false); } - - return Task.CompletedTask; }); return builder; @@ -412,78 +403,4 @@ public static IResourceBuilder WithInitBindMount(this IR return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly); } - - private static string WritePgWebBookmarks(IEnumerable postgresInstances, out byte[] contentHash) - { - var dir = Directory.CreateTempSubdirectory().FullName; - - // Fast, non-cryptographic hash. - var hash = new XxHash3(); - - foreach (var postgresDatabase in postgresInstances) - { - var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; - - // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - var fileContent = $""" - host = "{postgresDatabase.Parent.Name}" - port = {postgresDatabase.Parent.PrimaryEndpoint.TargetPort} - user = "{user}" - password = "{postgresDatabase.Parent.PasswordParameter.Value}" - database = "{postgresDatabase.DatabaseName}" - sslmode = "disable" - """; - - hash.Append(Encoding.UTF8.GetBytes(fileContent)); - - File.WriteAllText(Path.Combine(dir, $"{postgresDatabase.Name}.toml"), fileContent); - } - - contentHash = hash.GetCurrentHash(); - - return dir; - } - - private static string WritePgAdminServerJson(IEnumerable postgresInstances) - { - // This temporary file is not used by the container, it will be copied and then deleted - var filePath = Path.GetTempFileName(); - - using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Write); - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - - writer.WriteStartObject(); - writer.WriteStartObject("Servers"); - - var serverIndex = 1; - - foreach (var postgresInstance in postgresInstances) - { - if (postgresInstance.PrimaryEndpoint.IsAllocated) - { - var endpoint = postgresInstance.PrimaryEndpoint; - - writer.WriteStartObject($"{serverIndex}"); - writer.WriteString("Name", postgresInstance.Name); - writer.WriteString("Group", "Servers"); - // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - writer.WriteString("Host", endpoint.Resource.Name); - writer.WriteNumber("Port", (int)endpoint.TargetPort!); - writer.WriteString("Username", postgresInstance.UserNameParameter?.Value ?? "postgres"); - writer.WriteString("SSLMode", "prefer"); - writer.WriteString("MaintenanceDB", "postgres"); - writer.WriteString("PasswordExecCommand", $"echo '{postgresInstance.PasswordParameter.Value}'"); // HACK: Generating a pass file and playing around with chmod is too painful. - writer.WriteEndObject(); - } - - serverIndex++; - } - - writer.WriteEndObject(); - writer.WriteEndObject(); - - return filePath; - } } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 8fd21b01cd..9a6e290394 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1080,6 +1080,24 @@ private void PrepareContainers() ctr.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, containerObjectInstance.Suffix); SetInitialResourceState(container, ctr); + if (container.TryGetContainerMounts(out var containerMounts)) + { + ctr.Spec.VolumeMounts = []; + + foreach (var mount in containerMounts) + { + var volumeSpec = new VolumeMount + { + Source = mount.Source, + Target = mount.Target, + Type = mount.Type == ContainerMountType.BindMount ? VolumeMountType.Bind : VolumeMountType.Volume, + IsReadOnly = mount.IsReadOnly + }; + + ctr.Spec.VolumeMounts.Add(volumeSpec); + } + } + ctr.Spec.Networks = new List { new ContainerNetworkConnection @@ -1188,8 +1206,6 @@ private async Task CreateContainerAsync(AppResource cr, ILogger resourceLogger, spec.Ports = BuildContainerPorts(cr); } - spec.VolumeMounts = BuildContainerMounts(modelContainerResource); - (spec.RunArgs, var failedToApplyRunArgs) = await BuildRunArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); (var args, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); @@ -1630,27 +1646,4 @@ private static List BuildContainerPorts(AppResource cr) return ports; } - - private static List BuildContainerMounts(IResource container) - { - var volumeMounts = new List(); - - if (container.TryGetContainerMounts(out var containerMounts)) - { - foreach (var mount in containerMounts) - { - var volumeSpec = new VolumeMount - { - Source = mount.Source, - Target = mount.Target, - Type = mount.Type == ContainerMountType.BindMount ? VolumeMountType.Bind : VolumeMountType.Volume, - IsReadOnly = mount.IsReadOnly - }; - - volumeMounts.Add(volumeSpec); - } - } - - return volumeMounts; - } } diff --git a/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs b/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs index dfeb49778a..993686b4cc 100644 --- a/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs +++ b/tests/Aspire.Hosting.Garnet.Tests/GarnetFunctionalTests.cs @@ -131,7 +131,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) if (!OperatingSystem.IsWindows()) { - // The docker container runs as a non-root user, so we need to grant other user's read/write permission + // the docker container runs as a non-root user, so we need to grant other user's read/write permission // to the bind mount directory. // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. const UnixFileMode BindMountPermissions = diff --git a/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs b/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs index 9f13d878a2..d278dcd79b 100644 --- a/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs +++ b/tests/Aspire.Hosting.Kafka.Tests/KafkaFunctionalTests.cs @@ -142,7 +142,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) if (!OperatingSystem.IsWindows()) { - // The docker container runs as a non-root user, so we need to grant other user's read/write permission + // the docker container runs as a non-root user, so we need to grant other user's read/write permission // to the bind mount directory. // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. const UnixFileMode BindMountPermissions = @@ -152,7 +152,6 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) File.SetUnixFileMode(bindMountPath, BindMountPermissions); } - kafka1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs b/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs index 39b4d09a8c..9de82b41b8 100644 --- a/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs +++ b/tests/Aspire.Hosting.Milvus.Tests/MilvusFunctionalTests.cs @@ -93,9 +93,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } else { - // Milvus container runs as root and will create the directory. bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - milvus1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs index 263ae4feb8..1ee1f047d2 100644 --- a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs +++ b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs @@ -134,9 +134,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } else { - // MongoDB container runs as root and will create the directory. bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - mongodb1.WithDataBindMount(bindMountPath); } @@ -260,7 +258,18 @@ public async Task VerifyWithInitBindMount() .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) .Build(); - var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var bindMountPath = Directory.CreateTempSubdirectory().FullName; + + if (!OperatingSystem.IsWindows()) + { + // Change permissions for non-root accounts (container user account) + const UnixFileMode OwnershipPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(bindMountPath, OwnershipPermissions); + } try { diff --git a/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs b/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs index 31744a625f..ca2626bec3 100644 --- a/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs @@ -245,22 +245,28 @@ public async Task SingleMySqlInstanceProducesCorrectMySqlHostsVariable() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(myAdmin, DistributedApplicationOperation.Run, TestServiceProvider.Instance); - var container = builder.Resources.Single(r => r.Name == "mySql-phpmyadmin"); - Assert.Empty(container.Annotations.OfType()); - Assert.Equal($"{mysql.Resource.Name}:{mysql.Resource.PrimaryEndpoint.TargetPort}", config["PMA_HOST"]); Assert.NotNull(config["PMA_USER"]); Assert.NotNull(config["PMA_PASSWORD"]); } [Fact] - public void WithPhpMyAdminProducesValidServerConfigFile() + public void WithPhpMyAdminAddsContainer() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); + builder.AddMySql("mySql").WithPhpMyAdmin(); - var tempStorePath = Directory.CreateTempSubdirectory().FullName; - builder.Configuration["Aspire:Store:Path"] = tempStorePath; + var container = builder.Resources.Single(r => r.Name == "mySql-phpmyadmin"); + var volume = container.Annotations.OfType().Single(); + Assert.True(File.Exists(volume.Source)); // File should exist, but will be empty. + Assert.Equal("/etc/phpmyadmin/config.user.inc.php", volume.Target); + } + + [Fact] + public void WithPhpMyAdminProducesValidServerConfigFile() + { + var builder = DistributedApplication.CreateBuilder(); var mysql1 = builder.AddMySql("mysql1").WithPhpMyAdmin(c => c.WithHostPort(8081)); var mysql2 = builder.AddMySql("mysql2").WithPhpMyAdmin(c => c.WithHostPort(8081)); @@ -268,14 +274,14 @@ public void WithPhpMyAdminProducesValidServerConfigFile() mysql1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); mysql2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host3")); + var myAdmin = builder.Resources.Single(r => r.Name.EndsWith("-phpmyadmin")); + var volume = myAdmin.Annotations.OfType().Single(); + using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); - var myAdmin = builder.Resources.Single(r => r.Name.EndsWith("-phpmyadmin")); - var volume = myAdmin.Annotations.OfType().Single(); - using var stream = File.OpenRead(volume.Source!); var fileContents = new StreamReader(stream).ReadToEnd(); @@ -286,15 +292,6 @@ public void WithPhpMyAdminProducesValidServerConfigFile() Assert.True(match1.Success); Match match2 = Regex.Match(fileContents, pattern2); Assert.True(match2.Success); - - try - { - Directory.Delete(tempStorePath, true); - } - catch - { - // Ignore. - } } [Fact] diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs index 8eba92ca0d..150887726d 100644 --- a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs @@ -146,7 +146,6 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; - mysql1.WithDataBindMount(bindMountPath); } @@ -304,10 +303,15 @@ public async Task VerifyWithInitBindMount() var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(bindMountPath); - try { + if (Directory.Exists(bindMountPath)) + { + Directory.Delete(bindMountPath); + } + + Directory.CreateDirectory(bindMountPath); + File.WriteAllText(Path.Combine(bindMountPath, "init.sql"), """ CREATE TABLE cars (brand VARCHAR(255)); INSERT INTO cars (brand) VALUES ('BatMobile'); diff --git a/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs b/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs index 23c1800652..19b0df4ab9 100644 --- a/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs +++ b/tests/Aspire.Hosting.Nats.Tests/NatsFunctionalTests.cs @@ -175,7 +175,6 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; - nats1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs index 81b68437b3..06197cdf36 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs @@ -369,17 +369,12 @@ public async Task VerifyManifestWithParameters() } [Fact] - public async Task WithPgAdminAddsContainer() + public void WithPgAdminAddsContainer() { + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddPostgres("mypostgres").WithPgAdmin(pga => pga.WithHostPort(8081)); - using var app = builder.Build(); - var appModel = app.Services.GetRequiredService(); - - // The mount annotation is added in the AfterEndpointsAllocatedEvent. - await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); - var container = builder.Resources.Single(r => r.Name == "mypostgres-pgadmin"); var volume = container.Annotations.OfType().Single(); @@ -448,17 +443,13 @@ public void WithPostgresTwiceEndsUpWithOneContainer() builder.AddPostgres("mypostgres1").WithPgAdmin(pga => pga.WithHostPort(8081)); builder.AddPostgres("mypostgres2").WithPgAdmin(pga => pga.WithHostPort(8081)); - Assert.Single(builder.Resources.Where(r => r.Name.EndsWith("-pgadmin"))); + builder.Resources.Single(r => r.Name.EndsWith("-pgadmin")); } [Fact] public async Task WithPostgresProducesValidServersJsonFile() { var builder = DistributedApplication.CreateBuilder(); - - var tempStorePath = Directory.CreateTempSubdirectory().FullName; - builder.Configuration["Aspire:Store:Path"] = tempStorePath; - var username = builder.AddParameter("pg-user", "myuser"); var pg1 = builder.AddPostgres("mypostgres1").WithPgAdmin(pga => pga.WithHostPort(8081)); var pg2 = builder.AddPostgres("mypostgres2", username).WithPgAdmin(pga => pga.WithHostPort(8081)); @@ -467,13 +458,13 @@ public async Task WithPostgresProducesValidServersJsonFile() pg1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); pg2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host2")); + var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgadmin")); + var volume = pgadmin.Annotations.OfType().Single(); + using var app = builder.Build(); await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); - var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgadmin")); - var volume = pgadmin.Annotations.OfType().Single(); - using var stream = File.OpenRead(volume.Source!); var document = JsonDocument.Parse(stream); @@ -498,25 +489,12 @@ public async Task WithPostgresProducesValidServersJsonFile() Assert.Equal("prefer", servers.GetProperty("2").GetProperty("SSLMode").GetString()); Assert.Equal("postgres", servers.GetProperty("2").GetProperty("MaintenanceDB").GetString()); Assert.Equal($"echo '{pg2.Resource.PasswordParameter.Value}'", servers.GetProperty("2").GetProperty("PasswordExecCommand").GetString()); - - try - { - Directory.Delete(tempStorePath, true); - } - catch - { - // Ignore. - } } [Fact] public async Task WithPgwebProducesValidBookmarkFiles() { var builder = DistributedApplication.CreateBuilder(); - - var tempStorePath = Directory.CreateTempSubdirectory().FullName; - builder.Configuration["Aspire:Store:Path"] = tempStorePath; - var pg1 = builder.AddPostgres("mypostgres1").WithPgWeb(pga => pga.WithHostPort(8081)); var pg2 = builder.AddPostgres("mypostgres2").WithPgWeb(pga => pga.WithHostPort(8081)); @@ -527,14 +505,14 @@ public async Task WithPgwebProducesValidBookmarkFiles() var db1 = pg1.AddDatabase("db1"); var db2 = pg2.AddDatabase("db2"); + var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgweb")); + var volume = pgadmin.Annotations.OfType().Single(); + using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); - var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgweb")); - var volume = pgadmin.Annotations.OfType().Single(); - var bookMarkFiles = Directory.GetFiles(volume.Source!).OrderBy(f => f).ToArray(); Assert.Collection(bookMarkFiles, @@ -564,15 +542,6 @@ public async Task WithPgwebProducesValidBookmarkFiles() { Assert.Equal(CreatePgWebBookmarkfileContent(db2.Resource), content); }); - - try - { - Directory.Delete(tempStorePath, true); - } - catch - { - // Ignore. - } } [Fact] diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs index a4e6726411..e8e2c85cd8 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -221,8 +221,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) } else { - bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - + bindMountPath = Directory.CreateTempSubdirectory().FullName; postgres1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs index 11ef25ac23..79a73a8bd3 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs @@ -115,7 +115,6 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - qdrant1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs index b30f9b3013..5450c92d58 100644 --- a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs @@ -120,7 +120,6 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; - rabbitMQ1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 7d0c141a0e..fd83a9b06b 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -380,7 +380,10 @@ public async Task WithDataBindMountShouldPersistStateBetweenUsages() { var bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(bindMountPath); + if (!Directory.Exists(bindMountPath)) + { + Directory.CreateDirectory(bindMountPath); + } // Use a bind mount to do a snapshot save @@ -564,7 +567,6 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - redisInsightBuilder1.WithDataBindMount(bindMountPath); } diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index 8427d31d72..e24fc32478 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -119,6 +119,7 @@ await pipeline.ExecuteAsync(async token => [RequiresDocker] public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) { + string? volumeName = null; string? bindMountPath = null; @@ -148,23 +149,25 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(bindMountPath); + sqlserver1.WithDataBindMount(bindMountPath); + if (!OperatingSystem.IsWindows()) { - // The docker container runs as a non-root user, so we need to grant other user's read/write permission - // to the bind mount directory. - // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. - const UnixFileMode BindMountPermissions = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - - File.SetUnixFileMode(bindMountPath, BindMountPermissions); + // Change permissions for non-root accounts (container user account) + // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-security?view=sql-server-ver16#storagepermissions + + // This is the minimal set to get the tests passing. + const UnixFileMode MsSqlPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(bindMountPath, MsSqlPermissions); + File.SetUnixFileMode($"{bindMountPath}/data", MsSqlPermissions); + File.SetUnixFileMode($"{bindMountPath}/log", MsSqlPermissions); + File.SetUnixFileMode($"{bindMountPath}/secrets", MsSqlPermissions); } - - sqlserver1.WithDataBindMount(bindMountPath); } using var app1 = builder1.Build(); diff --git a/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs b/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs index bede5a1047..f6e89c8ab4 100644 --- a/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs +++ b/tests/Aspire.Hosting.Valkey.Tests/ValkeyFunctionalTests.cs @@ -81,7 +81,6 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - valkey1.WithDataBindMount(bindMountPath); } From e431b4fedf7d6e6d9ebca3113e0d718afa1421d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 4 Mar 2025 07:32:24 -0800 Subject: [PATCH 6/8] Disable flaky functional test --- .../AzureServiceBusExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index b8d2ba36c4..a81da01bab 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -215,7 +215,7 @@ public async Task VerifyWaitForOnServiceBusEmulatorBlocksDependentResources() await app.StopAsync(); } - [Fact] + [Fact(Skip = "Azure ServiceBus emulator is not reliable in CI - https://github.com/dotnet/aspire/issues/7066")] [RequiresDocker] public async Task VerifyAzureServiceBusEmulatorResource() { From 06338030c6651b2995c7faf7aae534ec1eb2ae61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 4 Mar 2025 07:36:25 -0800 Subject: [PATCH 7/8] Fix SQL Server functional test --- .../SqlServerFunctionalTests.cs | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index e24fc32478..7461ce84a1 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -149,25 +149,23 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(bindMountPath); - sqlserver1.WithDataBindMount(bindMountPath); + Directory.CreateDirectory(bindMountPath); if (!OperatingSystem.IsWindows()) { - // Change permissions for non-root accounts (container user account) - // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-security?view=sql-server-ver16#storagepermissions - - // This is the minimal set to get the tests passing. - const UnixFileMode MsSqlPermissions = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - - File.SetUnixFileMode(bindMountPath, MsSqlPermissions); - File.SetUnixFileMode($"{bindMountPath}/data", MsSqlPermissions); - File.SetUnixFileMode($"{bindMountPath}/log", MsSqlPermissions); - File.SetUnixFileMode($"{bindMountPath}/secrets", MsSqlPermissions); + // The docker container runs as a non-root user, so we need to grant other user's read/write permission + // to the bind mount directory. + // Note that we need to do this after creating the directory, because the umask is applied at the time of creation. + const UnixFileMode BindMountPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(bindMountPath, BindMountPermissions); } + + sqlserver1.WithDataBindMount(bindMountPath); } using var app1 = builder1.Build(); From d5452b55d6f5daafe77742aaaba1aef3853d0b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 11 Mar 2025 13:04:07 -0700 Subject: [PATCH 8/8] Fix Sql Server container permissions on Windows --- .../SqlServerBuilderExtensions.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 5d7d2bb1ce..b2f270e63d 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -110,6 +110,24 @@ public static IResourceBuilder WithDataBindMount(this I ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(source); - return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); + if (!OperatingSystem.IsWindows()) + { + return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); + } + else + { + // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver15&pivots=cs1-bash#mount-a-host-directory-as-data-volume + + foreach (var dir in new string[] { "data", "log", "secrets" }) + { + var path = Path.Combine(source, dir); + + Directory.CreateDirectory(path); + + builder.WithBindMount(path, $"/var/opt/mssql/{dir}", isReadOnly); + } + + return builder; + } } }