Skip to content
Merged
49 changes: 15 additions & 34 deletions src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,39 +162,21 @@ public static IResourceBuilder<T> WithPgAdmin<T>(this IResourceBuilder<T> builde

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);
// Generate the contents of the servers.json file
var serversConfigFile = WritePgAdminServerJson(postgresInstances);

// 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
{
try
{
File.Delete(tempConfigFile);
}
catch
// Create a servers.json file in the container with necessary permissions
pgAdminContainerBuilder.WithCreateFile("/pgadmin4", new()
{
}
}
new ContainerFile
{
Name = "servers.json",
Contents = serversConfigFile,
},
},
defaultMode: FileMode644);

return Task.CompletedTask;
});
Expand Down Expand Up @@ -452,10 +434,7 @@ private static string WritePgWebBookmarks(IEnumerable<PostgresDatabaseResource>

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 @@ -486,6 +465,8 @@ private static string WritePgAdminServerJson(IEnumerable<PostgresServerResource>
writer.WriteEndObject();
writer.WriteEndObject();

return filePath;
writer.Flush();

return Encoding.UTF8.GetString(stream.ToArray());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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
{
/// <summary>
/// The name of the file or directory.
/// </summary>
public required string Name { get; set; }

/// <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 to create in the container.
/// </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.
/// </summary>
public List<ContainerFileSystemItem> Entries { get; set; } = new();
}

/// <summary>
/// Represents an annotation that indicates the creation of a file in a container.
/// </summary>
[DebuggerDisplay("Type = {GetType().Name,nw}")]
public sealed class ContainerCreateFileAnnotation : 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 default permissions for files/directories to be created or updated in the container. 0 will be treated as 0600.
/// </summary>
public UnixFileMode DefaultMode { get; init; }

/// <summary>
/// The list of file system entries to create in the container.
/// </summary>
public List<ContainerFileSystemItem> Entries { get; init; } = new();
}
70 changes: 70 additions & 0 deletions src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,76 @@ public static IResourceBuilder<T> WithBuildSecret<T>(this IResourceBuilder<T> bu
return builder;
}

/// <summary>
/// Creates or updates files and/or folders at the destination path in the container.
/// </summary>
/// <typeparam name="T">The type of container resource.</typeparam>
/// <param name="builder">The resource builder for the container resource.</param>
/// <param name="destinationPath">The destination (absolute) path in the container.</param>
/// <param name="entries">The file system entries to create.</param>
/// <param name="defaultOwner">The default owner UID for the created or updated file system. Defaults to 0 for root.</param>
/// <param name="defaultGroup">The default group ID for the created or updated file system. Defaults to 0 for root.</param>
/// <param name="defaultMode">The default <see cref="UnixFileMode"/> ownership permissions for the created or updated file system. <see cref="UnixFileMode.None"/> will be treated as 0600 by DCP.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// For containers with a <see cref="ContainerLifetime.Persistent"/> lifetime, changing the contents of create file entries will result in the container being recreated.
/// Make sure any data being written to containers is idempotent for a given app model configuration. Specifically, be careful not to include any data that will be
/// unique on a per-run basis.
/// </para>
/// </remarks>
/// <example>
/// Create a directory called <c>custom-entry</c> in the container's file system at the path <c>/usr/data</c> and create a file called <c>entrypoint.sh</c> inside it with the content <c>echo hello world</c>.
/// The default permissions for these files will be for the user or group to be able to read and write to the files, but not execute them. entrypoint.sh will be created with execution permissions for the owner.
/// <code language="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddContainer("mycontainer", "myimage")
/// .WithCreateFile("/usr/data", new()
/// {
/// new ContainerDirectory
/// {
/// Name = "custom-entry",
/// Entries = new()
/// {
/// new ContainerFile
/// {
/// Name = "entrypoint.sh",
/// Contents = "echo hello world",
/// Mode = UnixFileMode.UserExecute | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite,
/// },
/// },
/// },
/// },
/// defaultOwner: 1000,
/// defaultMode: UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite);
/// </code>
/// </example>
public static IResourceBuilder<T> WithCreateFile<T>(this IResourceBuilder<T> builder, string destinationPath, List<ContainerFileSystemItem> entries, int defaultOwner = 0, int defaultGroup = 0, UnixFileMode defaultMode = UnixFileMode.None) where T : ContainerResource
Copy link
Member

Choose a reason for hiding this comment

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

Yeah, so I've been thinking about this some more. I think what we need to do with this API is make it take an async callback (maybe we can have some simple wrappers) which is evaluated just before the resource is created.

This would allow for very late configuration scenarios where you might want to grab some state from the app model to generate file content.

So I am thinking something like:

public static IResourceBuilder<T> WithContainerFiles(this IResourceBuilder<T> builder, Func<ContainerFilesContext, Task> callback);

The usage might look something like this:

var c = builder.AddContainer(...)
               .WithContainerFiles(async context => {
                  _ = context.Model; // Access to app model.
                  _ = context.ServiceProvider = // Access to DI container.
               });

This would allow us to do things like interrogate other resources that might have already started to download configurations from them and then inject them into another container.

Copy link
Member

Choose a reason for hiding this comment

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

You would end up with multiple container files annotations on a resource, and the callbacks would be invoked in order on the same context. The context itself would have methods that allow you to attach files/directories.

Copy link
Member

Choose a reason for hiding this comment

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

You could also have a simplified version of the method with the following signature:

public static IResourceBuilder<T> WithContainerFiles(this IResourceBuilder<T> builder, params (string, int) files);

Usage would be as follows usage might be as follows:

var c = builder.AddContainer(...)
               .WithContainerFiles([
                 ("blah.txt", 777),
                 ("foo.bin", 644)
               ]);

I'm less certain about this API and we could live without it as I think this API in general is a more advanced scenario.

Copy link
Member Author

Choose a reason for hiding this comment

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

  1. Should we have the file system types or just have annotations that capture all the details directly?

I thought about having nested annotations, but didn't see that we'd used that pattern anywhere else, so was a bit hesitant to introduce it here.

  1. What happens with file content in terms of clean up. Is it temporarily put on disk and then removed?

We build a tar file in memory and then stream that to stdin for the docker cp command, so there's no temporary files to cleanup (technically podman cp does create and cleanup a temporary file themselves, but I want to create a PR against them to remove that behavior since the temp file they create is never actually used).

  1. The WithCreateFile API seems like a weird name to me. I'd probably do something like WithContainerFile or something.

WithContainerFiles seems like a solid choice. The destination path may seem a bit odd, but it is technically necessary as you have to give a base path in the container you want to add or modify your files/folders and any folders you add/update will have their ownership and permissions updated if they already exist (whereas the destination path won't be modified). With a callback model, we'd likely need to support nested callbacks for directories and files:

.WithContainerFiles("/", async context =>
{
    context.AddDirectory(".pgweb" d =>
    {
        d.AddDirectory("bookmarks", d =>
        {
            d.AddFile("somefile.toml", "contents", mode: FileMode644);
        });
    });
});

Copy link
Member

Choose a reason for hiding this comment

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

I think we need to be able to pass streams if not a file path (even/too). Because files might be big enough that we don't want to load them in memory at once.

Copy link
Member Author

Choose a reason for hiding this comment

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

@mitchdenny I’ve implemented a callback version of the API and annotation, but need to update the functional tests based on the change.

Copy link
Member Author

Choose a reason for hiding this comment

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

Tests and API are updated.

Copy link
Member Author

Choose a reason for hiding this comment

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

Chatted with @mitchdenny offline; I'm updating the default permission behavior to match that of *nix systems (umask based default permissions).

Copy link
Member

Choose a reason for hiding this comment

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

Is the API in the description the only APi? It’s super verbose. Where’s the easy mode APi?

Copy link
Member Author

@danegsta danegsta Mar 19, 2025

Choose a reason for hiding this comment

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

There's a non-callback version that's a little simplified, but most of the complexity comes from the fact that the API is letting us do direct file system manipulation in the container (and that manipulation has to happen at a specific existing path in the container). We're manipulating a file tree, so we ended up with a tree structure in the API.

If we did add a simpler API method, it'd be a bit weird in any slightly complicated scenario (here's an example based on the pgweb bookmark file creation):

// Creates new bookmark files under the newly created /.pgweb/bookmarks/
WithContainerFiles(
    // We're creating folders and files relative to the root of the filesystem (/)
    destinationPath: "/",
    files: [
        // We need to specify the full path for each file since the .pgweb/bookmarks/ portion of the path is newly created
        (".pgweb/bookmarks/someserver.toml", "contents"),
        (".pgweb/bookmarks/otherserver.toml", "contents"),
    ]);

The AppHost would have to handle parsing and generating the tree structure that DCP expects from those file references.

{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(destinationPath);
ArgumentNullException.ThrowIfNull(entries);

foreach (var existingAnnotation in builder.Resource.Annotations.OfType<ContainerCreateFileAnnotation>().Where(a => string.Equals(a.DestinationPath, destinationPath, StringComparison.Ordinal)))
{
builder.Resource.Annotations.Remove(existingAnnotation);
}

var annotation = new ContainerCreateFileAnnotation
{
DestinationPath = destinationPath,
Entries = entries,
DefaultOwner = defaultOwner,
DefaultGroup = defaultGroup,
DefaultMode = defaultMode,
};

builder.Resource.Annotations.Add(annotation);

return builder;
}

/// <summary>
/// Set whether a container resource can use proxied endpoints or whether they should be disabled for all endpoints belonging to the container.
/// If set to <c>false</c>, endpoints belonging to the container resource will ignore the configured proxy settings and run proxy-less.
Expand Down
13 changes: 13 additions & 0 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,19 @@ private async Task CreateContainerAsync(AppResource cr, ILogger resourceLogger,

spec.VolumeMounts = BuildContainerMounts(modelContainerResource);

if (cr.ModelResource.TryGetAnnotationsOfType<ContainerCreateFileAnnotation>(out var createFileAnnotations))
{
spec.CreateFiles = createFileAnnotations
.Select(a => new ContainerCreateFileSystem
{
Destination = a.DestinationPath,
DefaultOwner = a.DefaultOwner,
DefaultGroup = a.DefaultGroup,
Mode = (int)a.DefaultMode,
Entries = a.Entries.Select(e => e.ToContainerFileSystemEntry()).ToList(),
}).ToList();
}

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

(var args, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false);
Expand Down
Loading
Loading