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 diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index d22f612ec2..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. /// @@ -322,6 +324,12 @@ 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()) + { + File.SetUnixFileMode(configJsonPath, FileMode644); + } + builder.WithAnnotation(new ContainerMountAnnotation( configJsonPath, AzureEventHubsEmulatorResource.EmulatorConfigJsonPath, @@ -425,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 9861a74229..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. /// @@ -423,6 +425,12 @@ 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()) + { + File.SetUnixFileMode(configJsonPath, FileMode644); + } + builder.WithAnnotation(new ContainerMountAnnotation( configJsonPath, AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, @@ -550,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.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index e7af6de482..5b9e84ddf7 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -17,7 +17,7 @@ public static class PostgresBuilderExtensions { private const string UserEnvVarName = "POSTGRES_USER"; private const string PasswordEnvVarName = "POSTGRES_PASSWORD"; - + /// /// Adds a PostgreSQL resource to the application model. A container is used for local development. /// @@ -308,8 +308,8 @@ public static IResourceBuilder WithPgWeb(this IResourceB if (!OperatingSystem.IsWindows()) { var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; File.SetUnixFileMode(serverFileMount.Source!, mode); } diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 4674604a80..b2f270e63d 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -101,25 +101,33 @@ 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" }) + if (!OperatingSystem.IsWindows()) + { + return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); + } + else { - var path = Path.Combine(source, dir); + // 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 - if (!Directory.Exists(path)) + 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); } - builder.WithBindMount(path, $"/var/opt/mssql/{dir}", isReadOnly); + return builder; } - - return builder; } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 1af15125d2..3ccc93360b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -110,6 +110,46 @@ public async Task VerifyAzureEventHubsEmulatorResource(bool referenceHub) } } + [Fact] + [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(); + 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 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"))], cts.Token); + + 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 +433,16 @@ 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.GroupRead | UnixFileMode.OtherRead; + + 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..a81da01bab 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -470,6 +470,16 @@ 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.GroupRead | UnixFileMode.OtherRead; + + Assert.True(fileInfo.UnixFileMode.HasFlag(expectedUnixFileMode)); + } + var configJsonContent = File.ReadAllText(volumeAnnotation.Source!); Assert.Equal(/*json*/""" 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(); 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; } }