Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -325,10 +327,7 @@ public static IResourceBuilder<AzureEventHubsResource> 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(
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 @@ -426,10 +428,7 @@ public static IResourceBuilder<AzureServiceBusResource> 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(
Expand Down
201 changes: 137 additions & 64 deletions src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +18,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;

/// <summary>
/// Adds a PostgreSQL resource to the application model. A container is used for local development.
Expand Down Expand Up @@ -150,56 +152,45 @@ public static IResourceBuilder<T> WithPgAdmin<T>(this IResourceBuilder<T> builde
.WithImageRegistry(PostgresContainerImageTags.PgAdminRegistry)
.WithHttpEndpoint(targetPort: 80, name: "http")
.WithEnvironment(SetPgAdminEnvironmentVariables)
.WithBindMount(Path.GetTempFileName(), "/pgadmin4/servers.json")
.WithHttpHealthCheck("/browser")
.ExcludeFromManifest();

builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((e, ct) =>
{
var serverFileMount = pgAdminContainer.Annotations.OfType<ContainerMountAnnotation>().Single(v => v.Target == "/pgadmin4/servers.json");
// Add the servers.json file bind mount to the pgAdmin container

var postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresServerResource>();

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<IAspireStore>();

var serverIndex = 1;
// Deterministic file path for the configuration file based on its content
var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-pgadmin-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;
});

Expand Down Expand Up @@ -277,13 +268,11 @@ public static IResourceBuilder<PostgresServerResource> 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();
Expand All @@ -294,44 +283,55 @@ public static IResourceBuilder<PostgresServerResource> WithPgWeb(this IResourceB

pgwebContainerBuilder.WithHttpHealthCheck();

builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>(async (e, ct) =>
builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((e, ct) =>
{
var adminResource = builder.ApplicationBuilder.Resources.OfType<PgWebContainerResource>().Single();
var serverFileMount = adminResource.Annotations.OfType<ContainerMountAnnotation>().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. Note that when using dynamic proxies the configuration won't be reused, but it only makes
// sense for persistent containers (when pg servers are reused) in which case they would have fixed ports.

var postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresDatabaseResource>();

if (!Directory.Exists(serverFileMount.Source!))
{
Directory.CreateDirectory(serverFileMount.Source!);
}
var aspireStore = e.Services.GetRequiredService<IAspireStore>();

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, $"{pgwebContainerBuilder.Resource.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);
}
// Need to grant read access to the config file on unix like systems.
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(pgwebBookmarks, FileMode644);
}

foreach (var file in Directory.GetFiles(tempDir))
{
// Target is overwritten just in case the previous attempts has failed
File.Copy(file, Path.Combine(pgwebBookmarks, Path.GetFileName(file)), overwrite: true);
}

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;
Expand Down Expand Up @@ -403,4 +403,77 @@ public static IResourceBuilder<PostgresServerResource> WithInitBindMount(this IR

return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
}

private static string WritePgWebBookmarks(IEnumerable<PostgresDatabaseResource> 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<PostgresServerResource> postgresInstances)
{
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;
}
}
43 changes: 25 additions & 18 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContainerNetworkConnection>
{
new ContainerNetworkConnection
Expand Down Expand Up @@ -1206,6 +1188,8 @@ private async Task CreateContainerAsync(AppResource cr, ILogger resourceLogger,
spec.Ports = BuildContainerPorts(cr);
}

spec.VolumeMounts = BuildBindMounts(modelContainerResource);
Copy link
Member Author

@sebastienros sebastienros Feb 26, 2025

Choose a reason for hiding this comment

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

The idea is to be able to add container mount annotations during the AftertEndpointAllocated. The same way endpoints are built in CreateContainerAsync. Otherwise we can't create file mounts with deterministic names based on the file content (for persistent containers).

NB: In the case of MySql/PgSql the target ports are currently used, which are static. Resolution is done thought host name since it's docker to docker communication. This means that at least this part of the configuration in invariant across restarts.

Copy link
Member

Choose a reason for hiding this comment

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


(spec.RunArgs, var failedToApplyRunArgs) = await BuildRunArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false);

(var args, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -1646,4 +1630,27 @@ private static List<ContainerPortSpec> BuildContainerPorts(AppResource cr)

return ports;
}

private static List<VolumeMount> BuildBindMounts(IResource container)
{
var volumeMounts = new List<VolumeMount>();

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
Loading
Loading