Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[release/9.1] Ensure EH and SB emulator files can be used from non-root containers #7765

Open
wants to merge 8 commits into
base: release/9.1
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
<!-- This repo version -->
<MajorVersion>9</MajorVersion>
<MinorVersion>1</MinorVersion>
<PatchVersion>0</PatchVersion>
<PatchVersion>1</PatchVersion>
<VersionPrefix>$(MajorVersion).$(MinorVersion).$(PatchVersion)</VersionPrefix>
<PreReleaseVersionLabel>preview.1</PreReleaseVersionLabel>
<PreReleaseVersionLabel>servicing</PreReleaseVersionLabel>
<DefaultTargetFramework>net8.0</DefaultTargetFramework>
<AllTargetFrameworks>$(DefaultTargetFramework);net9.0</AllTargetFrameworks>
<!-- dotnet 8.0 versions for running tests -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ namespace Aspire.Hosting;
/// </summary>
public static class AzureEventHubsExtensions
{
private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;

/// <summary>
/// Adds an Azure Event Hubs Namespace resource to the application model. This resource can be used to create Event Hub resources.
/// </summary>
Expand Down Expand Up @@ -322,6 +324,12 @@ public static IResourceBuilder<AzureEventHubsResource> 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,
Expand Down Expand Up @@ -425,6 +433,7 @@ public static IResourceBuilder<AzureEventHubsEmulatorResource> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ namespace Aspire.Hosting;
/// </summary>
public static class AzureServiceBusExtensions
{
private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;

/// <summary>
/// Adds an Azure Service Bus Namespace resource to the application model. This resource can be used to create queue, topic, and subscription resources.
/// </summary>
Expand Down Expand Up @@ -423,6 +425,12 @@ public static IResourceBuilder<AzureServiceBusResource> 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,
Expand Down Expand Up @@ -550,6 +558,7 @@ public static IResourceBuilder<AzureServiceBusEmulatorResource> 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);
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public static class PostgresBuilderExtensions
{
private const string UserEnvVarName = "POSTGRES_USER";
private const string PasswordEnvVarName = "POSTGRES_PASSWORD";

/// <summary>
/// Adds a PostgreSQL resource to the application model. A container is used for local development.
/// </summary>
Expand Down Expand Up @@ -308,8 +308,8 @@ public static IResourceBuilder<PostgresServerResource> 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);
}
Expand Down
24 changes: 16 additions & 8 deletions src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,25 +101,33 @@ public static IResourceBuilder<SqlServerServerResource> WithDataVolume(this IRes
/// <param name="source">The source directory on the host to mount into the container.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only mount.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// The container starts up as non-root and the <paramref name="source"/> 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&amp;pivots=cs1-bash#mount-a-host-directory-as-data-volume
/// </remarks>
public static IResourceBuilder<SqlServerServerResource> WithDataBindMount(this IResourceBuilder<SqlServerServerResource> 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebastienros - What do you think about not porting this change at all for release/9.1?

I think the risk here isn't worth it. Sql emulator in 9.1 currently "works" as it did in 9.0 right? So we are only adding risk here. I think we should keep this change targeted to the broken scenarios.


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;
}
}
50 changes: 50 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResourceNotificationService>();
await rns.WaitForResourceHealthyAsync(eventHubns.Resource.Name, cts.Token);

var producerClient = host.Services.GetRequiredService<EventHubProducerClient>();
var consumerClient = host.Services.GetRequiredService<EventHubConsumerClient>();

// 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()
{
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,16 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson()
var serviceBusEmulatorResource = builder.Resources.OfType<AzureServiceBusResource>().Single(x => x is { } serviceBusResource && serviceBusResource.IsEmulator);
var volumeAnnotation = serviceBusEmulatorResource.Annotations.OfType<ContainerMountAnnotation>().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*/"""
Expand Down
26 changes: 12 additions & 14 deletions tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}