Skip to content

Add support for copying existing files via WithContainerFiles API #8908

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

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
27 changes: 13 additions & 14 deletions src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public static class KeycloakResourceBuilderExtensions
private const int DefaultContainerPort = 8080;
private const int ManagementInterfaceContainerPort = 9000; // As per https://www.keycloak.org/server/management-interface
private const string ManagementEndpointName = "management";
private const string RealmImportDirectory = "/opt/keycloak/data/import";

private const string KeycloakDataDirectory = "/opt/keycloak/data";

/// <summary>
/// Adds a Keycloak container to the application model.
Expand Down Expand Up @@ -161,18 +162,16 @@ public static IResourceBuilder<KeycloakResource> WithRealmImport(

var importFullPath = Path.GetFullPath(import, builder.ApplicationBuilder.AppHostDirectory);

if (Directory.Exists(importFullPath))
{
return builder.WithBindMount(importFullPath, RealmImportDirectory, isReadOnly);
}

if (File.Exists(importFullPath))
{
var fileName = Path.GetFileName(import);

return builder.WithBindMount(importFullPath, $"{RealmImportDirectory}/{fileName}", isReadOnly);
}

throw new InvalidOperationException($"The realm import file or directory '{importFullPath}' does not exist.");
return builder.WithContainerFiles(
KeycloakDataDirectory,
[
// The import directory may not exist by default, so we need to ensure it is created.
new ContainerDirectory
{
Name = "import",
// Import the file (or children if a directory) into the container.
Entries = ContainerDirectory.GetFileSystemItemsFromPath(importFullPath),
},
]);
}
}
8 changes: 7 additions & 1 deletion src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,13 @@ public static IResourceBuilder<PostgresServerResource> WithInitBindMount(this IR
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(source);

return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
const string initPath = "/docker-entrypoint-initdb.d";

var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);

return builder.WithContainerFiles(
initPath,
ContainerDirectory.GetFileSystemItemsFromPath(importFullPath));
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public string Name

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));
throw new ArgumentException($"Name '{value}' 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;
Expand Down Expand Up @@ -52,10 +52,17 @@ public string Name
/// </summary>
public sealed class ContainerFile : ContainerFileSystemItem
{

/// <summary>
/// The contents of the file. If null, the file will be created as an empty file.
/// The contents of the file. Setting Contents is mutually exclusive with <see cref="SourcePath"/>. If both are set, an exception will be thrown.
/// </summary>
public string? Contents { get; set; }

/// <summary>
/// The path to a file on the host system to copy into the container. This path must be absolute and point to a file on the host system.
Copy link
Member

Choose a reason for hiding this comment

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

Why does the path need to be absolute? We can use relative paths elsewhere.

Copy link
Member Author

Choose a reason for hiding this comment

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

We don’t explicitly check for an absolute path; it’s more of a SHOULD requirement. We can’t necessarily guarantee the orchestrator and container runtime will have the correct working directory in all scenarios, so absolute paths guarantees consistent behavior. We probably should call out the same for the bind mount annotation.

/// Setting SourcePath is mutually exclusive with <see cref="Contents"/>. If both are set, an exception will be thrown.
/// </summary>
public string? SourcePath { get; set; }
}

/// <summary>
Expand All @@ -67,6 +74,110 @@ public sealed class ContainerDirectory : ContainerFileSystemItem
/// 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; } = [];

private class FileTree : Dictionary<string, FileTree>
{
public required ContainerFileSystemItem Value { get; set; }

public static IEnumerable<ContainerFileSystemItem> GetItems(KeyValuePair<string, FileTree> node)
{
return node.Value.Value switch
{
ContainerDirectory dir => [
new ContainerDirectory
{
Name = dir.Name,
Entries = node.Value.SelectMany(GetItems),
},
],
ContainerFile file => [file],
_ => throw new InvalidOperationException($"Unknown file system item type: {node.Value.GetType().Name}"),
};
}
}

/// <summary>
/// Enumerates files from a specified directory and converts them to <see cref="ContainerFile"/> objects.
/// </summary>
/// <param name="path">The directory path to enumerate files from.</param>
/// <param name="searchPattern"></param>
/// <param name="searchOptions"></param>
/// <returns>
/// An enumerable collection of <see cref="ContainerFileSystemItem"/> objects.
/// </returns>
/// <exception cref="DirectoryNotFoundException">Thrown when the specified path does not exist.</exception>
public static IEnumerable<ContainerFileSystemItem> GetFileSystemItemsFromPath(string path, string searchPattern = "*", SearchOption searchOptions = SearchOption.TopDirectoryOnly)
{
var fullPath = Path.GetFullPath(path);

if (Directory.Exists(fullPath))
{
// Build a tree of the directories and files found
FileTree root = new FileTree
{
Value = new ContainerDirectory
{
Name = "root",
}
};

foreach (var file in Directory.GetFiles(path, searchPattern, searchOptions).Order(StringComparer.Ordinal))
{
var relativePath = file.Substring(fullPath.Length + 1);
var fileName = Path.GetFileName(relativePath);
var parts = relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
var node = root;
foreach (var part in parts.SkipLast(1))
{
if (node.TryGetValue(part, out var childNode))
{
node = childNode;
}
else
{
var newNode = new FileTree
{
Value = new ContainerDirectory
{
Name = part,
}
};
node.Add(part, newNode);
node = newNode;
}
}

node.Add(fileName, new FileTree
{
Value = new ContainerFile
{
Name = fileName,
SourcePath = file,
}
});
}

return root.SelectMany(FileTree.GetItems);
}

if (File.Exists(fullPath))
{
if (searchPattern != "*")
{
throw new ArgumentException($"A search pattern was specified, but the given path '{fullPath}' is a file. Search patterns are only valid for directories.", nameof(searchPattern));
}

return [
new ContainerFile
{
Name = Path.GetFileName(fullPath),
SourcePath = fullPath,
}
];
}

throw new InvalidOperationException($"The specified path '{fullPath}' does not exist.");
}
}

/// <summary>
Expand Down
13 changes: 12 additions & 1 deletion src/Aspire.Hosting/Dcp/Model/Container.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,13 @@ public static ContainerFileSystemEntry ToContainerFileSystemEntry(this Container

if (item is ContainerFile file)
{
entry.Source = file.SourcePath;
entry.Contents = file.Contents;

if (file.Contents is not null && file.SourcePath is not null)
{
throw new ArgumentException("Both SourcePath and Contents are set for a file entry");
}
}
else if (item is ContainerDirectory directory)
{
Expand Down Expand Up @@ -381,7 +387,11 @@ internal sealed class ContainerFileSystemEntry : IEquatable<ContainerFileSystemE
[JsonPropertyName("mode")]
public int Mode { get; set; }

// If the file system entry is a file, this is the contents of that file. Setting Contents for a directory is an error.
// If the file system entry is a file, this is the optional path to a file on the host to use as the contents of that file.
[JsonPropertyName("source")]
public string? Source { get; set; }

// If the file system entry is a file, this is the contents of that file. Setting Contents for a directory is an error. Contents and Source are mutually exclusive.
[JsonPropertyName("contents")]
public string? Contents { get; set; }

Expand All @@ -401,6 +411,7 @@ public bool Equals(ContainerFileSystemEntry? other)
&& Owner == other.Owner
&& Group == other.Group
&& Mode == other.Mode
&& Source == other.Source
&& Contents == other.Contents
&& (Entries ?? Enumerable.Empty<ContainerFileSystemEntry>()).SequenceEqual(other.Entries ?? Enumerable.Empty<ContainerFileSystemEntry>());
}
Expand Down
36 changes: 24 additions & 12 deletions tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public void WithRealmImportShouldThrowWhenImportDoesNotExist()
[InlineData(null)]
[InlineData(true)]
[InlineData(false)]
public void WithRealmImportDirectoryAddsBindMountAnnotation(bool? isReadOnly)
public async Task WithRealmImportDirectoryAddsContainerFilesAnnotation(bool? isReadOnly)
{
using var builder = TestDistributedApplicationBuilder.Create();

Expand All @@ -170,19 +170,24 @@ public void WithRealmImportDirectoryAddsBindMountAnnotation(bool? isReadOnly)
keycloak.WithRealmImport(tempDirectory);
}

var containerAnnotation = keycloak.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
using var app = builder.Build();
var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal));

Assert.Equal(tempDirectory, containerAnnotation.Source);
Assert.Equal("/opt/keycloak/data/import", containerAnnotation.Target);
Assert.Equal(ContainerMountType.BindMount, containerAnnotation.Type);
Assert.Equal(isReadOnly ?? false, containerAnnotation.IsReadOnly);
var containerAnnotation = keycloak.Resource.Annotations.OfType<ContainerFileSystemCallbackAnnotation>().Single();

var entries = await containerAnnotation.Callback(new() { Model = keycloakResource, ServiceProvider = app.Services }, CancellationToken.None);

Assert.Equal("/opt/keycloak/data", containerAnnotation.DestinationPath);
var importDirectory = Assert.IsType<ContainerDirectory>(entries.First());
Assert.Equal("import", importDirectory.Name);
Assert.Empty(importDirectory.Entries);
}

[Theory]
[InlineData(null)]
[InlineData(true)]
[InlineData(false)]
public void WithRealmImportFileAddsBindMountAnnotation(bool? isReadOnly)
public async Task WithRealmImportFileAddsContainerFilesAnnotation(bool? isReadOnly)
{
using var builder = TestDistributedApplicationBuilder.Create();

Expand All @@ -205,11 +210,18 @@ public void WithRealmImportFileAddsBindMountAnnotation(bool? isReadOnly)
keycloak.WithRealmImport(filePath);
}

var containerAnnotation = keycloak.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
using var app = builder.Build();
var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal));

var containerAnnotation = keycloak.Resource.Annotations.OfType<ContainerFileSystemCallbackAnnotation>().Single();

var entries = await containerAnnotation.Callback(new() { Model = keycloakResource, ServiceProvider = app.Services }, CancellationToken.None);

Assert.Equal(filePath, containerAnnotation.Source);
Assert.Equal($"/opt/keycloak/data/import/{file}", containerAnnotation.Target);
Assert.Equal(ContainerMountType.BindMount, containerAnnotation.Type);
Assert.Equal(isReadOnly ?? false, containerAnnotation.IsReadOnly);
Assert.Equal("/opt/keycloak/data", containerAnnotation.DestinationPath);
var importDirectory = Assert.IsType<ContainerDirectory>(entries.First());
Assert.Equal("import", importDirectory.Name);
var realmFile = Assert.IsType<ContainerFile>(Assert.Single(importDirectory.Entries));
Assert.Equal(file, realmFile.Name);
Assert.Equal(filePath, realmFile.SourcePath);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,8 @@ public async Task VerifyWithInitBindMount()
INSERT INTO "Cars" (brand) VALUES ('BatMobile');
""");

using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
using var builder = TestDistributedApplicationBuilder
.CreateWithTestContainerRegistry(testOutputHelper);

var postgresDbName = "db1";
var postgres = builder.AddPostgres("pg").WithEnvironment("POSTGRES_DB", postgresDbName);
Expand Down
6 changes: 6 additions & 0 deletions tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,12 @@ public async Task VerifyContainerCreateFile()
Contents = "Hello World!",
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite,
},
new ContainerFile
{
Name = "test2.sh",
SourcePath = "/tmp/test2.sh",
Mode = UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead,
},
],
},
};
Expand Down