Skip to content
Merged
154 changes: 44 additions & 110 deletions src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,11 +19,6 @@ 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;

/// <summary>
/// Adds a PostgreSQL resource to the application model. A container is used for local development.
Expand Down Expand Up @@ -207,44 +201,21 @@ public static IResourceBuilder<T> WithPgAdmin<T>(this IResourceBuilder<T> builde
.WithHttpHealthCheck("/browser")
.ExcludeFromManifest();

builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((e, ct) =>
{
// Add the servers.json file bind mount to the pgAdmin container

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

// Create servers.json file content in a temporary file

var tempConfigFile = WritePgAdminServerJson(postgresInstances);

try
{
var aspireStore = e.Services.GetRequiredService<IAspireStore>();

// Deterministic file path for the configuration file based on its content
var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-servers.json", tempConfigFile);

// Need to grant read access to the config file on unix like systems.
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(configJsonPath, FileMode644);
}

pgAdminContainerBuilder.WithBindMount(configJsonPath, "/pgadmin4/servers.json");
}
finally
pgAdminContainerBuilder.WithContainerFiles(
destinationPath: "/pgadmin4",
callback: (context, _) =>
{
try
{
File.Delete(tempConfigFile);
}
catch
{
}
}
var appModel = context.ServiceProvider.GetRequiredService<DistributedApplicationModel>();
var postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresServerResource>();

return Task.CompletedTask;
});
return Task.FromResult<IEnumerable<ContainerFileSystemItem>>([
new ContainerFile
{
Name = "servers.json",
Contents = WritePgAdminServerJson(postgresInstances),
},
]);
});

configureContainer?.Invoke(pgAdminContainerBuilder);

Expand Down Expand Up @@ -339,61 +310,28 @@ public static IResourceBuilder<PostgresServerResource> WithPgWeb(this IResourceB

pgwebContainerBuilder.WithHttpHealthCheck();

builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((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 postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresDatabaseResource>();

var aspireStore = e.Services.GetRequiredService<IAspireStore>();

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
pgwebContainerBuilder.WithContainerFiles(
destinationPath: "/",
callback: (context, _) =>
{
Directory.CreateDirectory(pgwebBookmarks);

// Grant listing access to the bookmarks folder on unix like systems.
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(pgwebBookmarks, FileMode755);
}
var appModel = context.ServiceProvider.GetRequiredService<DistributedApplicationModel>();
var postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresDatabaseResource>();

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())
// Add the bookmarks to the pgweb container
return Task.FromResult<IEnumerable<ContainerFileSystemItem>>([
new ContainerDirectory
{
File.SetUnixFileMode(destinationPath, FileMode644);
}
}

pgwebContainerBuilder.WithBindMount(pgwebBookmarks, "/.pgweb/bookmarks");
}
finally
{
try
{
Directory.Delete(tempDir, true);
}
catch
{
}
}

return Task.CompletedTask;
});
Name = ".pgweb",
Entries = [
new ContainerDirectory
{
Name = "bookmarks",
Entries = WritePgWebBookmarks(postgresInstances),
},
],
},
]);
});

return builder;
}
Expand Down Expand Up @@ -486,12 +424,9 @@ public static IResourceBuilder<PostgresDatabaseResource> WithCreationScript(this
return builder;
}

private static string WritePgWebBookmarks(IEnumerable<PostgresDatabaseResource> postgresInstances, out byte[] contentHash)
private static IEnumerable<ContainerFileSystemItem> WritePgWebBookmarks(IEnumerable<PostgresDatabaseResource> postgresInstances)
{
var dir = Directory.CreateTempSubdirectory().FullName;

// Fast, non-cryptographic hash.
var hash = new XxHash3();
var bookmarkFiles = new List<ContainerFileSystemItem>();

foreach (var postgresDatabase in postgresInstances)
{
Expand All @@ -508,22 +443,19 @@ private static string WritePgWebBookmarks(IEnumerable<PostgresDatabaseResource>
sslmode = "disable"
""";

hash.Append(Encoding.UTF8.GetBytes(fileContent));

File.WriteAllText(Path.Combine(dir, $"{postgresDatabase.Name}.toml"), fileContent);
bookmarkFiles.Add(new ContainerFile
{
Name = $"{postgresDatabase.Name}.toml",
Contents = fileContent,
});
}

contentHash = hash.GetCurrentHash();

return dir;
return bookmarkFiles;
}

private static string WritePgAdminServerJson(IEnumerable<PostgresServerResource> 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 stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });

writer.WriteStartObject();
Expand Down Expand Up @@ -554,7 +486,9 @@ private static string WritePgAdminServerJson(IEnumerable<PostgresServerResource>
writer.WriteEndObject();
writer.WriteEndObject();

return filePath;
writer.Flush();

return Encoding.UTF8.GetString(stream.ToArray());
}

private static async Task CreateDatabaseAsync(NpgsqlConnection npgsqlConnection, PostgresDatabaseResource npgsqlDatabase, IServiceProvider serviceProvider, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents a base class for file system entries in a container.
/// </summary>
public abstract class ContainerFileSystemItem
{
private string? _name;

/// <summary>
/// The name of the file or directory. Must be a simple file or folder name and not include any path separators (eg, / or \). To specify parent folders, use one or more <see cref="ContainerDirectory"/> entries.
/// </summary>
public string Name
{
get => _name!;
set
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, nameof(value));

if (Path.GetDirectoryName(value) != string.Empty)
{
throw new ArgumentException("Name must be a simple file or folder name and not include any path separators (eg, / or \\). To specify parent folders, use one or more ContainerDirectory entries.", nameof(value));
}

_name = value;
}
}

/// <summary>
/// The UID of the owner of the file or directory. If set to null, the UID will be inherited from the parent directory or defaults.
/// </summary>
public int? Owner { get; set; }

/// <summary>
/// The GID of the group of the file or directory. If set to null, the GID will be inherited from the parent directory or defaults.
/// </summary>
public int? Group { get; set; }

/// <summary>
/// The permissions of the file or directory. If set to 0, the permissions will be inherited from the parent directory or defaults.
/// </summary>
public UnixFileMode Mode { get; set; }
}

/// <summary>
/// Represents a file in the container file system.
/// </summary>
public sealed class ContainerFile : ContainerFileSystemItem
{
/// <summary>
/// The contents of the file. If null, the file will be created as an empty file.
/// </summary>
public string? Contents { get; set; }
}

/// <summary>
/// Represents a directory in the container file system.
/// </summary>
public sealed class ContainerDirectory : ContainerFileSystemItem
{
/// <summary>
/// The contents of the directory to create in the container. Will create specified <see cref="ContainerFile"/> and <see cref="ContainerDirectory"/> entries in the directory.
/// </summary>
public IEnumerable<ContainerFileSystemItem> Entries { get; set; } = [];
}

/// <summary>
/// Represents a callback annotation that specifies files and folders that should be created or updated in a container.
/// </summary>
[DebuggerDisplay("Type = {GetType().Name,nw}, DestinationPath = {DestinationPath}")]
public sealed class ContainerCreateFilesCallbackAnnotation : IResourceAnnotation
{
/// <summary>
/// The (absolute) base path to create the new file (and any parent directories) in the container.
/// This path should already exist in the container.
/// </summary>
public required string DestinationPath { get; init; }

/// <summary>
/// The UID of the default owner for files/directories to be created or updated in the container. Defaults to 0 for root.
/// </summary>
public int DefaultOwner { get; init; }

/// <summary>
/// The GID of the default group for files/directories to be created or updated in the container. Defaults to 0 for root.
/// </summary>
public int DefaultGroup { get; init; }

/// <summary>
/// The umask to apply to files or folders without an explicit mode permission. If set to null, a default umask value of 0022 (octal) will be used.
/// The umask takes away permissions from the default permission set (rather than granting them).
/// </summary>
/// <remarks>
/// The umask is a bitmask that determines the default permissions for newly created files and directories. The umask value is subtracted (bitwise masked)
/// from the maximum possible default permissions to determine the final permissions. For directories, the umask is subtracted from 0777 (rwxrwxrwx) to get
/// the final permissions and for files it is subtracted from 0666 (rw-rw-rw-). For a umask of 0022, this gives a default folder permission of 0755 (rwxr-xr-x)
/// and a default file permission of 0644 (rw-r--r--).
/// </remarks>
public UnixFileMode? Umask { get; set; }

/// <summary>
/// The callback to be executed when the container is created. Should return a tree of <see cref="ContainerFileSystemItem"/> entries to create (or update) in the container.
/// </summary>
public required Func<ContainerCreateFilesCallbackContext, CancellationToken, Task<IEnumerable<ContainerFileSystemItem>>> Callback { get; init; }
}

/// <summary>
/// Represents the context for a <see cref="ContainerCreateFilesCallbackAnnotation"/> callback.
/// </summary>
public sealed class ContainerCreateFilesCallbackContext
{
/// <summary>
/// A <see cref="IServiceProvider"/> that can be used to resolve services in the callback.
/// </summary>
public required IServiceProvider ServiceProvider { get; init; }

/// <summary>
/// The app model resource the callback is associated with.
/// </summary>
public required IResource Model { get; init; }
}
Loading