diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsEmulatorResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsEmulatorResource.cs
index 6d57386af9b..8619dbb2e12 100644
--- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsEmulatorResource.cs
+++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsEmulatorResource.cs
@@ -13,7 +13,10 @@ public class AzureEventHubsEmulatorResource(AzureEventHubsResource innerResource
: ContainerResource(innerResource.Name), IResource
{
// The path to the emulator configuration file in the container.
- internal const string EmulatorConfigJsonPath = "/Eventhubs_Emulator/ConfigFiles/Config.json";
+ // The path to the emulator configuration files in the container.
+ internal const string EmulatorConfigFilesPath = "/Eventhubs_Emulator/ConfigFiles";
+ // The path to the emulator configuration file in the container.
+ internal const string EmulatorConfigJsonFile = "Config.json";
private readonly AzureEventHubsResource _innerResource = innerResource ?? throw new ArgumentNullException(nameof(innerResource));
diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs
index fd52c07cb31..e4a26e5675d 100644
--- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs
+++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs
@@ -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.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting;
@@ -9,7 +10,6 @@
using Aspire.Hosting.Azure.EventHubs;
using Azure.Provisioning;
using Azure.Provisioning.EventHubs;
-using Microsoft.Extensions.DependencyInjection;
using AzureProvisioning = Azure.Provisioning.EventHubs;
namespace Aspire.Hosting;
@@ -19,8 +19,6 @@ namespace Aspire.Hosting;
///
public static class AzureEventHubsExtensions
{
- private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
-
private const string EmulatorHealthEndpointName = "emulatorhealth";
///
@@ -31,7 +29,7 @@ public static class AzureEventHubsExtensions
/// A reference to the .
///
/// By default references to the Azure AppEvent Hubs Namespace resource will be assigned the following roles:
- ///
+ ///
/// -
///
/// These can be replaced by calling .
@@ -240,11 +238,10 @@ public static IResourceBuilder RunAsEmulator(this IResou
var lifetime = ContainerLifetime.Session;
// Copy the lifetime from the main resource to the storage resource
-
+ var surrogate = new AzureEventHubsEmulatorResource(builder.Resource);
+ var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate);
if (configureContainer != null)
{
- var surrogate = new AzureEventHubsEmulatorResource(builder.Resource);
- var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate);
configureContainer(surrogateBuilder);
if (surrogate.TryGetLastAnnotation(out var lifetimeAnnotation))
@@ -269,77 +266,55 @@ public static IResourceBuilder RunAsEmulator(this IResou
// RunAsEmulator() can be followed by custom model configuration so we need to delay the creation of the Config.json file
// until all resources are about to be prepared and annotations can't be updated anymore.
-
- builder.ApplicationBuilder.Eventing.Subscribe((@event, ct) =>
- {
- // Create JSON configuration file
-
- var hasCustomConfigJson = builder.Resource.Annotations.OfType().Any(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath);
-
- if (hasCustomConfigJson)
+ surrogateBuilder.WithContainerFiles(
+ AzureEventHubsEmulatorResource.EmulatorConfigFilesPath,
+ (_, _) =>
{
- return Task.CompletedTask;
- }
+ var customConfigFile = builder.Resource.Annotations.OfType().FirstOrDefault();
+ if (customConfigFile != null)
+ {
+ return Task.FromResult>([
+ new ContainerFile
+ {
+ Name = AzureEventHubsEmulatorResource.EmulatorConfigJsonFile,
+ SourcePath = customConfigFile.SourcePath,
+ },
+ ]);
+ }
- // Create Config.json file content and its alterations in a temporary file
- var tempConfigFile = WriteEmulatorConfigJson(builder.Resource);
+ // Create default Config.json file content
+ var tempConfig = JsonNode.Parse(CreateEmulatorConfigJson(builder.Resource));
+
+ if (tempConfig == null)
+ {
+ throw new InvalidOperationException("The configuration file mount could not be parsed.");
+ }
- try
- {
// Apply ConfigJsonAnnotation modifications
var configJsonAnnotations = builder.Resource.Annotations.OfType();
if (configJsonAnnotations.Any())
{
- using var readStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Read);
- var jsonObject = JsonNode.Parse(readStream);
- readStream.Close();
-
- if (jsonObject == null)
- {
- throw new InvalidOperationException("The configuration file mount could not be parsed.");
- }
-
foreach (var annotation in configJsonAnnotations)
{
- annotation.Configure(jsonObject);
+ annotation.Configure(tempConfig);
}
-
- using var writeStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Write);
- using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true });
- jsonObject.WriteTo(writer);
}
- var aspireStore = @event.Services.GetRequiredService();
-
- // 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);
- }
+ using var writeStream = new MemoryStream();
+ using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true });
+ tempConfig.WriteTo(writer);
- builder.WithAnnotation(new ContainerMountAnnotation(
- configJsonPath,
- AzureEventHubsEmulatorResource.EmulatorConfigJsonPath,
- ContainerMountType.BindMount,
- isReadOnly: true));
- }
- finally
- {
- try
- {
- File.Delete(tempConfigFile);
- }
- catch
- {
- }
- }
+ writer.Flush();
- return Task.CompletedTask;
- });
+ return Task.FromResult>([
+ new ContainerFile
+ {
+ Name = AzureEventHubsEmulatorResource.EmulatorConfigJsonFile,
+ Contents = Encoding.UTF8.GetString(writeStream.ToArray()),
+ },
+ ]);
+ });
return builder;
}
@@ -413,14 +388,7 @@ public static IResourceBuilder WithConfiguration
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(path);
- // Update the existing mount
- var configFileMount = builder.Resource.Annotations.OfType().LastOrDefault(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath);
- if (configFileMount != null)
- {
- builder.Resource.Annotations.Remove(configFileMount);
- }
-
- return builder.WithBindMount(path, AzureEventHubsEmulatorResource.EmulatorConfigJsonPath, isReadOnly: true);
+ return builder.WithAnnotation(new ConfigFileAnnotation(path), ResourceAnnotationMutationBehavior.Replace);
}
///
@@ -439,12 +407,9 @@ public static IResourceBuilder WithConfiguration
return builder;
}
- private static string WriteEmulatorConfigJson(AzureEventHubsResource emulatorResource)
+ private static string CreateEmulatorConfigJson(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);
+ using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject(); // {
@@ -474,7 +439,9 @@ private static string WriteEmulatorConfigJson(AzureEventHubsResource emulatorRes
writer.WriteEndObject(); // } (/UserConfig)
writer.WriteEndObject(); // } (/Root)
- return filePath;
+ writer.Flush();
+
+ return Encoding.UTF8.GetString(stream.ToArray());
}
///
@@ -491,7 +458,7 @@ private static string WriteEmulatorConfigJson(AzureEventHubsResource emulatorRes
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var eventHubs = builder.AddAzureEventHubs("eventHubs");
- ///
+ ///
/// var api = builder.AddProject<Projects.Api>("api")
/// .WithRoleAssignments(eventHubs, EventHubsBuiltInRole.AzureEventHubsDataSender)
/// .WithReference(eventHubs);
diff --git a/src/Aspire.Hosting.Azure.EventHubs/ConfigFileAnnotation.cs b/src/Aspire.Hosting.Azure.EventHubs/ConfigFileAnnotation.cs
new file mode 100644
index 00000000000..796978b4994
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.EventHubs/ConfigFileAnnotation.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Azure.EventHubs;
+
+///
+/// Represents an annotation for a custom config file source.
+///
+internal sealed class ConfigFileAnnotation : IResourceAnnotation
+{
+ public ConfigFileAnnotation(string sourcePath)
+ {
+ SourcePath = sourcePath ?? throw new ArgumentNullException(nameof(sourcePath));
+ }
+
+ public string SourcePath { get; }
+}
diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusEmulatorResource.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusEmulatorResource.cs
index 37e0eeea82f..ebc78cac43a 100644
--- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusEmulatorResource.cs
+++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusEmulatorResource.cs
@@ -11,8 +11,10 @@ namespace Aspire.Hosting.Azure;
/// The inner resource used to store annotations.
public class AzureServiceBusEmulatorResource(AzureServiceBusResource innerResource) : ContainerResource(innerResource.Name), IResource
{
+ // The path to the emulator configuration files in the container.
+ internal const string EmulatorConfigFilesPath = "/ServiceBus_Emulator/ConfigFiles";
// The path to the emulator configuration file in the container.
- internal const string EmulatorConfigJsonPath = "/ServiceBus_Emulator/ConfigFiles/Config.json";
+ internal const string EmulatorConfigJsonFile = "Config.json";
private readonly AzureServiceBusResource _innerResource = innerResource ?? throw new ArgumentNullException(nameof(innerResource));
diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs
index 8834e31b6a8..da1a4ce2040 100644
--- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs
+++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs
@@ -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.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting;
@@ -9,7 +10,6 @@
using Aspire.Hosting.Azure.ServiceBus;
using Azure.Provisioning;
using Azure.Provisioning.ServiceBus;
-using Microsoft.Extensions.DependencyInjection;
using AzureProvisioning = Azure.Provisioning.ServiceBus;
namespace Aspire.Hosting;
@@ -19,8 +19,6 @@ namespace Aspire.Hosting;
///
public static class AzureServiceBusExtensions
{
- private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
-
private const string EmulatorHealthEndpointName = "emulatorhealth";
///
@@ -31,7 +29,7 @@ public static class AzureServiceBusExtensions
/// A reference to the .
///
/// By default references to the Azure Service Bus resource will be assigned the following roles:
- ///
+ ///
/// -
///
/// These can be replaced by calling .
@@ -389,10 +387,11 @@ public static IResourceBuilder RunAsEmulator(this IReso
var lifetime = ContainerLifetime.Session;
+ var surrogate = new AzureServiceBusEmulatorResource(builder.Resource);
+ var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate);
+
if (configureContainer != null)
{
- var surrogate = new AzureServiceBusEmulatorResource(builder.Resource);
- var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate);
configureContainer(surrogateBuilder);
if (surrogate.TryGetLastAnnotation(out var lifetimeAnnotation))
@@ -405,77 +404,56 @@ public static IResourceBuilder RunAsEmulator(this IReso
// RunAsEmulator() can be followed by custom model configuration so we need to delay the creation of the Config.json file
// until all resources are about to be prepared and annotations can't be updated anymore.
-
- builder.ApplicationBuilder.Eventing.Subscribe((@event, ct) =>
- {
- // Create JSON configuration file
-
- var hasCustomConfigJson = builder.Resource.Annotations.OfType().Any(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath);
-
- if (hasCustomConfigJson)
+ surrogateBuilder.WithContainerFiles(
+ AzureServiceBusEmulatorResource.EmulatorConfigFilesPath,
+ (_, _) =>
{
- return Task.CompletedTask;
- }
+ var customConfigFile = builder.Resource.Annotations.OfType().FirstOrDefault();
+ if (customConfigFile != null)
+ {
+ return Task.FromResult>([
+ new ContainerFile
+ {
+ Name = AzureServiceBusEmulatorResource.EmulatorConfigJsonFile,
+ SourcePath = customConfigFile.SourcePath,
+ },
+ ]);
+ }
- // Create Config.json file content and its alterations in a temporary file
- var tempConfigFile = WriteEmulatorConfigJson(builder.Resource);
+ // Create default Config.json file content
+ var tempConfig = JsonNode.Parse(CreateEmulatorConfigJson(builder.Resource));
+
+ if (tempConfig == null)
+ {
+ throw new InvalidOperationException("The configuration file mount could not be parsed.");
+ }
- try
- {
// Apply ConfigJsonAnnotation modifications
var configJsonAnnotations = builder.Resource.Annotations.OfType();
if (configJsonAnnotations.Any())
{
- using var readStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Read);
- var jsonObject = JsonNode.Parse(readStream);
- readStream.Close();
-
- if (jsonObject == null)
- {
- throw new InvalidOperationException("The configuration file mount could not be parsed.");
- }
-
foreach (var annotation in configJsonAnnotations)
{
- annotation.Configure(jsonObject);
+ annotation.Configure(tempConfig);
}
-
- using var writeStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Write);
- using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true });
- jsonObject.WriteTo(writer);
}
- var aspireStore = @event.Services.GetRequiredService();
+ using var writeStream = new MemoryStream();
+ using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true });
+ tempConfig.WriteTo(writer);
- // Deterministic file path for the configuration file based on its content
- var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile);
+ writer.Flush();
- // 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,
- ContainerMountType.BindMount,
- isReadOnly: true));
- }
- finally
- {
- try
- {
- File.Delete(tempConfigFile);
- }
- catch
- {
- }
+ return Task.FromResult>([
+ new ContainerFile
+ {
+ Name = AzureServiceBusEmulatorResource.EmulatorConfigJsonFile,
+ Contents = Encoding.UTF8.GetString(writeStream.ToArray()),
+ },
+ ]);
}
-
- return Task.CompletedTask;
- });
+ );
builder.WithHttpHealthCheck(endpointName: EmulatorHealthEndpointName, path: "/health");
@@ -483,7 +461,7 @@ public static IResourceBuilder RunAsEmulator(this IReso
}
///
- /// Adds a bind mount for the configuration file of an Azure Service Bus emulator resource.
+ /// Copies the configuration file into an Azure Service Bus emulator resource.
///
/// The builder for the .
/// Path to the file on the AppHost where the emulator configuration is located.
@@ -493,14 +471,7 @@ public static IResourceBuilder WithConfiguratio
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(path);
- // Update the existing mount
- var configFileMount = builder.Resource.Annotations.OfType().LastOrDefault(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath);
- if (configFileMount != null)
- {
- builder.Resource.Annotations.Remove(configFileMount);
- }
-
- return builder.WithBindMount(path, AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, isReadOnly: true);
+ return builder.WithAnnotation(new ConfigFileAnnotation(path), ResourceAnnotationMutationBehavior.Replace);
}
///
@@ -551,12 +522,9 @@ public static IResourceBuilder WithHostPort(thi
});
}
- private static string WriteEmulatorConfigJson(AzureServiceBusResource emulatorResource)
+ private static string CreateEmulatorConfigJson(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);
+ using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject(); // {
@@ -615,7 +583,9 @@ private static string WriteEmulatorConfigJson(AzureServiceBusResource emulatorRe
writer.WriteEndObject(); // } (/UserConfig)
writer.WriteEndObject(); // } (/Root)
- return filePath;
+ writer.Flush();
+
+ return Encoding.UTF8.GetString(stream.ToArray());
}
///
@@ -633,7 +603,7 @@ private static string WriteEmulatorConfigJson(AzureServiceBusResource emulatorRe
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var sb = builder.AddAzureServiceBus("bus");
- ///
+ ///
/// var api = builder.AddProject<Projects.Api>("api")
/// .WithRoleAssignments(sb, ServiceBusBuiltInRole.AzureServiceBusDataSender)
/// .WithReference(sb);
diff --git a/src/Aspire.Hosting.Azure.ServiceBus/ConfigFileAnnotation.cs b/src/Aspire.Hosting.Azure.ServiceBus/ConfigFileAnnotation.cs
new file mode 100644
index 00000000000..2561fd5078f
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.ServiceBus/ConfigFileAnnotation.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Azure.ServiceBus;
+
+///
+/// Represents an annotation for a custom config file source.
+///
+internal sealed class ConfigFileAnnotation : IResourceAnnotation
+{
+ public ConfigFileAnnotation(string sourcePath)
+ {
+ SourcePath = sourcePath ?? throw new ArgumentNullException(nameof(sourcePath));
+ }
+
+ public string SourcePath { get; }
+}
diff --git a/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs
index 4fcab3526f4..d2d4390859b 100644
--- a/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs
+++ b/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs
@@ -33,4 +33,7 @@ internal static partial class DockerComposePublisherLoggerExtensions
[LoggerMessage(LogLevel.Warning, "Not in publishing mode. Skipping writing docker-compose.yaml output file.")]
internal static partial void NotInPublishingMode(this ILogger logger);
+
+ [LoggerMessage(LogLevel.Error, "Failed to copy referenced file '{FilePath}' to '{OutputPath}'")]
+ internal static partial void FailedToCopyFile(this ILogger logger, string filePath, string outputPath);
}
diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
index 1846d8a6e11..2d0d75e0192 100644
--- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
+++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
@@ -3,6 +3,7 @@
#pragma warning disable ASPIREPUBLISHERS001
+using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Docker.Resources;
using Aspire.Hosting.Docker.Resources.ComposeNodes;
@@ -27,6 +28,11 @@ internal sealed class DockerComposePublishingContext(
ILogger logger,
CancellationToken cancellationToken = default)
{
+ private const UnixFileMode DefaultUmask = UnixFileMode.GroupExecute | UnixFileMode.GroupWrite | UnixFileMode.OtherExecute | UnixFileMode.OtherWrite;
+ private const UnixFileMode MaxDefaultFilePermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite |
+ UnixFileMode.GroupRead | UnixFileMode.GroupWrite |
+ UnixFileMode.OtherRead | UnixFileMode.OtherWrite;
+
public readonly IResourceContainerImageBuilder ImageBuilder = imageBuilder;
public readonly string OutputPath = outputPath;
@@ -83,6 +89,18 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
defaultNetwork.Name,
];
+ if (serviceResource.TargetResource.TryGetAnnotationsOfType(out var fsAnnotations))
+ {
+ foreach (var a in fsAnnotations)
+ {
+ var files = await a.Callback(new() { Model = serviceResource.TargetResource, ServiceProvider = executionContext.ServiceProvider }, CancellationToken.None).ConfigureAwait(false);
+ foreach (var file in files)
+ {
+ HandleComposeFileConfig(composeFile, composeService, file, a.DefaultOwner, a.DefaultGroup, a.Umask ?? DefaultUmask, a.DestinationPath);
+ }
+ }
+ }
+
if (serviceResource.TargetResource.TryGetAnnotationsOfType(out var annotations))
{
foreach (var a in annotations)
@@ -123,6 +141,64 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
envFile.Save(envFilePath);
}
+ private void HandleComposeFileConfig(ComposeFile composeFile, Service composeService, ContainerFileSystemItem? item, int? uid, int? gid, UnixFileMode umask, string path)
+ {
+ if (item is ContainerDirectory dir)
+ {
+ foreach (var dirItem in dir.Entries)
+ {
+ HandleComposeFileConfig(composeFile, composeService, dirItem, item.Owner ?? uid, item.Group ?? gid, umask, path += "/" + item.Name);
+ }
+
+ return;
+ }
+
+ if (item is ContainerFile file)
+ {
+ var name = composeService.Name + "_" + path.Replace('/', '_') + "_" + file.Name;
+
+ // If there is a source path, we should copy the file to the output path and use that instead.
+ string? sourcePath = null;
+ if (!string.IsNullOrEmpty(file.SourcePath))
+ {
+ try
+ {
+ // Determine the path to copy the file to
+ sourcePath = Path.Combine(OutputPath, composeService.Name, Path.GetFileName(file.SourcePath));
+ // Files will be copied to a subdirectory named after the service
+ Directory.CreateDirectory(Path.Combine(OutputPath, composeService.Name));
+ File.Copy(file.SourcePath, sourcePath);
+ // Use a relative path for the compose file to make it portable
+ // Use unix style path separators even on Windows
+ sourcePath = Path.GetRelativePath(OutputPath, sourcePath).Replace('\\', '/');
+ }
+ catch
+ {
+ logger.FailedToCopyFile(file.SourcePath, OutputPath);
+ throw;
+ }
+ }
+
+ composeFile.AddConfig(new()
+ {
+ Name = name,
+ File = sourcePath,
+ Content = file.Contents,
+ });
+
+ composeService.AddConfig(new()
+ {
+ Source = name,
+ Target = path + "/" + file.Name,
+ Uid = (item.Owner ?? uid)?.ToString(CultureInfo.InvariantCulture),
+ Gid = (item.Group ?? gid)?.ToString(CultureInfo.InvariantCulture),
+ Mode = item.Mode != 0 ? item.Mode : MaxDefaultFilePermissions & ~umask,
+ });
+
+ return;
+ }
+ }
+
private static void HandleComposeFileVolumes(DockerComposeServiceResource serviceResource, ComposeFile composeFile)
{
foreach (var volume in serviceResource.Volumes.Where(volume => volume.Type != "bind"))
diff --git a/src/Aspire.Hosting.Docker/Resources/ComposeFile.cs b/src/Aspire.Hosting.Docker/Resources/ComposeFile.cs
index 7591f1f4efe..3fb8c0cc2c2 100644
--- a/src/Aspire.Hosting.Docker/Resources/ComposeFile.cs
+++ b/src/Aspire.Hosting.Docker/Resources/ComposeFile.cs
@@ -139,6 +139,17 @@ public ComposeFile AddVolume(Volume volume)
return this;
}
+ ///
+ /// Adds a new config entry to the Compose file.
+ ///
+ /// The config instance to add to the Compose file.
+ /// The updated instance with the added config.
+ public ComposeFile AddConfig(Config config)
+ {
+ Configs[config.Name] = config;
+ return this;
+ }
+
///
/// Converts the current instance of to its YAML string representation.
///
@@ -148,6 +159,7 @@ public string ToYaml(string lineEndings = "\n")
{
var serializer = new SerializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .WithTypeConverter(new UnixFileModeTypeConverter())
.WithEventEmitter(nextEmitter => new StringSequencesFlowStyle(nextEmitter))
.WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter))
.WithEmissionPhaseObjectGraphVisitor(args => new YamlIEnumerableSkipEmptyObjectGraphVisitor(args.InnerVisitor))
diff --git a/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Config.cs b/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Config.cs
index c6905b33a5e..43966e4e0ab 100644
--- a/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Config.cs
+++ b/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Config.cs
@@ -14,7 +14,7 @@ namespace Aspire.Hosting.Docker.Resources.ComposeNodes;
/// source file, external flag, custom name, and additional labels for the configuration.
///
[YamlSerializable]
-public sealed class Config
+public sealed class Config : NamedComposeMember
{
///
/// Gets or sets the path to the configuration file.
@@ -34,10 +34,12 @@ public sealed class Config
public bool? External { get; set; }
///
- /// Represents the name of the Docker configuration resource as defined in the Compose file.
+ /// Gets or sets the contents of the configuration file.
+ /// This property is used to specify the actual configuration data
+ /// that will be included in the Docker Compose file.
///
- [YamlMember(Alias = "name")]
- public string? Name { get; set; }
+ [YamlMember(Alias = "content")]
+ public string? Content { get; set;}
///
/// Represents a collection of key-value pairs used as metadata
diff --git a/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Service.cs b/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Service.cs
index f7600d49bf3..7dc59b758c8 100644
--- a/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Service.cs
+++ b/src/Aspire.Hosting.Docker/Resources/ComposeNodes/Service.cs
@@ -478,4 +478,17 @@ public Service AddEnvironmentalVariable(string key, string? value)
return this;
}
+
+ ///
+ /// Adds a configuration reference to the service's list of configurations.
+ /// This method allows you to include external configuration resources
+ /// that the service can utilize at runtime.
+ ///
+ /// The config reference to add
+ /// The updated instance with the added environmental variable.
+ public Service AddConfig(ConfigReference config)
+ {
+ Configs.Add(config);
+ return this;
+ }
}
diff --git a/src/Aspire.Hosting.Docker/Resources/ServiceNodes/ConfigReference.cs b/src/Aspire.Hosting.Docker/Resources/ServiceNodes/ConfigReference.cs
index d51125a3d85..30b640d4c25 100644
--- a/src/Aspire.Hosting.Docker/Resources/ServiceNodes/ConfigReference.cs
+++ b/src/Aspire.Hosting.Docker/Resources/ServiceNodes/ConfigReference.cs
@@ -35,7 +35,7 @@ public sealed class ConfigReference
/// Optional property that specifies the user ID for accessing the configuration target.
///
[YamlMember(Alias = "uid")]
- public int? Uid { get; set; }
+ public string? Uid { get; set; }
///
/// Gets or sets the group ID (GID) used to identify the group of the referenced configuration.
@@ -45,7 +45,7 @@ public sealed class ConfigReference
/// for the resource. If set, it defines the group to which the target resource belongs.
///
[YamlMember(Alias = "gid")]
- public int? Gid { get; set; }
+ public string? Gid { get; set; }
///
/// Represents the access mode for the configuration reference in the form of an integer value.
@@ -53,5 +53,5 @@ public sealed class ConfigReference
/// Typical values might correspond to standard file permission modes.
///
[YamlMember(Alias = "mode")]
- public int? Mode { get; set; }
+ public UnixFileMode? Mode { get; set; }
}
diff --git a/src/Aspire.Hosting.Docker/UnixFileModeTypeConverter.cs b/src/Aspire.Hosting.Docker/UnixFileModeTypeConverter.cs
new file mode 100644
index 00000000000..a61b5c7ca01
--- /dev/null
+++ b/src/Aspire.Hosting.Docker/UnixFileModeTypeConverter.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+
+namespace Aspire.Hosting.Docker;
+
+internal class UnixFileModeTypeConverter : IYamlTypeConverter
+{
+ public bool Accepts(Type type)
+ {
+ return type == typeof(UnixFileMode);
+ }
+
+ public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
+ {
+ if (parser.Current is not YamlDotNet.Core.Events.Scalar scalar)
+ {
+ throw new InvalidOperationException(parser.Current?.ToString());
+ }
+
+ var value = scalar.Value;
+ parser.MoveNext();
+
+ return Convert.ToInt32(value, 8);
+ }
+
+ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
+ {
+ if (value is not UnixFileMode mode)
+ {
+ throw new InvalidOperationException($"Expected {nameof(UnixFileMode)} but got {value?.GetType()}");
+ }
+
+ emitter.Emit(new Scalar("0" + Convert.ToString((int)mode, 8)));
+ }
+}
\ No newline at end of file
diff --git a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs
index 773b514e068..d72dd51c196 100644
--- a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs
@@ -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";
///
/// Adds a Keycloak container to the application model.
@@ -142,7 +143,7 @@ public static IResourceBuilder WithDataBindMount(this IResourc
/// A flag that indicates if the realm import directory is read-only.
/// The .
///
- /// The realm import files are mounted at /opt/keycloak/data/import in the container.
+ /// The realm import files are copied to /opt/keycloak/data/import in the container.
///
/// Import the realms from a directory
///
@@ -151,28 +152,50 @@ public static IResourceBuilder WithDataBindMount(this IResourc
///
///
///
+ [Obsolete("Use WithRealmImport without isReadOnly instead.")]
public static IResourceBuilder WithRealmImport(
this IResourceBuilder builder,
string import,
- bool isReadOnly = false)
+ bool isReadOnly)
+ {
+ return builder.WithRealmImport(import);
+ }
+
+ ///
+ /// Adds a realm import to a Keycloak container resource.
+ ///
+ /// The resource builder.
+ /// The directory containing the realm import files or a single import file.
+ /// The .
+ ///
+ /// The realm import files are copied to /opt/keycloak/data/import in the container.
+ ///
+ /// Import the realms from a directory
+ ///
+ /// var keycloak = builder.AddKeycloak("keycloak")
+ /// .WithRealmImport("../realms");
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithRealmImport(
+ this IResourceBuilder builder,
+ string import)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(import);
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),
+ },
+ ]);
}
}
diff --git a/src/Aspire.Hosting.Milvus/MilvusBuilderExtensions.cs b/src/Aspire.Hosting.Milvus/MilvusBuilderExtensions.cs
index f5f3e68ce79..3f4c029a343 100644
--- a/src/Aspire.Hosting.Milvus/MilvusBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Milvus/MilvusBuilderExtensions.cs
@@ -31,7 +31,7 @@ public static class MilvusBuilderExtensions
///
/// builder.Build().Run();
///
- ///
+ ///
///
/// The .
/// The name of the resource. This name will be used as the connection string name when referenced in a dependency
@@ -180,8 +180,9 @@ public static IResourceBuilder WithDataBindMount(this IRes
/// Adds a bind mount for the configuration of a Milvus container resource.
///
/// The resource builder.
- /// The source directory on the host to mount into the container.
+ /// The configuration file on the host to mount into the container.
/// The .
+ [Obsolete("Use WithConfigurationFile instead.")]
public static IResourceBuilder WithConfigurationBindMount(this IResourceBuilder builder, string configurationFilePath)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -190,6 +191,26 @@ public static IResourceBuilder WithConfigurationBindMount(
return builder.WithBindMount(configurationFilePath, "/milvus/configs/milvus.yaml");
}
+ ///
+ /// Copies a configuration file into a Milvus container resource.
+ ///
+ /// The resource builder.
+ /// The configuration file on the host to copy into the container.
+ /// The .
+ public static IResourceBuilder WithConfigurationFile(this IResourceBuilder builder, string configurationFilePath)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(configurationFilePath);
+
+ return builder.WithContainerFiles("/milvus/configs", [
+ new ContainerFile
+ {
+ Name = "milvus.yaml",
+ SourcePath = configurationFilePath,
+ },
+ ]);
+ }
+
private static void ConfigureAttuContainer(EnvironmentCallbackContext context, MilvusServerResource resource)
{
// Attu assumes Milvus is being accessed over a default Aspire container network and hardcodes the resource address
diff --git a/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs b/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs
index c99666bb083..758b1a9ff52 100644
--- a/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs
+++ b/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs
@@ -217,6 +217,7 @@ public static IResourceBuilder WithDataBindMount(this IRe
/// The source directory on the host to mount into the container.
/// A flag that indicates if this is a read-only mount.
/// The .
+ [Obsolete("Use WithInitFiles instead.")]
public static IResourceBuilder WithInitBindMount(this IResourceBuilder builder, string source, bool isReadOnly = true)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -225,6 +226,26 @@ public static IResourceBuilder WithInitBindMount(this IRe
return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
}
+ ///
+ /// Copies init files into a MongoDB container resource.
+ ///
+ /// The resource builder.
+ /// The source file or directory on the host to copy into the container.
+ /// The .
+ public static IResourceBuilder WithInitFiles(this IResourceBuilder builder, string source)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(source);
+
+ const string initPath = "/docker-entrypoint-initdb.d";
+
+ var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);
+
+ return builder.WithContainerFiles(
+ initPath,
+ ContainerDirectory.GetFileSystemItemsFromPath(importFullPath));
+ }
+
private static void ConfigureMongoExpressContainer(EnvironmentCallbackContext context, MongoDBServerResource resource)
{
// Mongo Express assumes Mongo is being accessed over a default Aspire container network and hardcodes the resource address
diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs
index 6855715b026..0c103d6202c 100644
--- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs
+++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs
@@ -317,6 +317,7 @@ public static IResourceBuilder WithDataBindMount(this IReso
/// The source directory on the host to mount into the container.
/// A flag that indicates if this is a read-only mount.
/// The .
+ [Obsolete("Use WithInitFiles instead.")]
public static IResourceBuilder WithInitBindMount(this IResourceBuilder builder, string source, bool isReadOnly = true)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -325,6 +326,26 @@ public static IResourceBuilder WithInitBindMount(this IReso
return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
}
+ ///
+ /// Copies init files into a MySql container resource.
+ ///
+ /// The resource builder.
+ /// The source file or directory on the host to copy into the container.
+ /// The .
+ public static IResourceBuilder WithInitFiles(this IResourceBuilder builder, string source)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(source);
+
+ const string initPath = "/docker-entrypoint-initdb.d";
+
+ var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);
+
+ return builder.WithContainerFiles(
+ initPath,
+ ContainerDirectory.GetFileSystemItemsFromPath(importFullPath));
+ }
+
private static string WritePhpMyAdminConfiguration(IEnumerable mySqlInstances)
{
// This temporary file is not used by the container, it will be copied and then deleted
diff --git a/src/Aspire.Hosting.Oracle/OracleDatabaseBuilderExtensions.cs b/src/Aspire.Hosting.Oracle/OracleDatabaseBuilderExtensions.cs
index 80b4f710c85..db04d0282f6 100644
--- a/src/Aspire.Hosting.Oracle/OracleDatabaseBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Oracle/OracleDatabaseBuilderExtensions.cs
@@ -121,6 +121,7 @@ public static IResourceBuilder WithDataBindMount(t
/// The resource builder.
/// The source directory on the host to mount into the container.
/// The .
+ [Obsolete("Use WithInitFiles instead.")]
public static IResourceBuilder WithInitBindMount(this IResourceBuilder builder, string source)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -129,6 +130,26 @@ public static IResourceBuilder WithInitBindMount(t
return builder.WithBindMount(source, "/opt/oracle/scripts/startup", false);
}
+ ///
+ /// Copies init files into a Oracle Database server container resource.
+ ///
+ /// The resource builder.
+ /// The source file or directory on the host to copy into the container.
+ /// The .
+ public static IResourceBuilder WithInitFiles(this IResourceBuilder builder, string source)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(source);
+
+ const string initPath = "/docker-entrypoint-initdb.d";
+
+ var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);
+
+ return builder.WithContainerFiles(
+ initPath,
+ ContainerDirectory.GetFileSystemItemsFromPath(importFullPath));
+ }
+
///
/// Adds a bind mount for the database setup folder to a Oracle Database server container resource.
///
diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
index 4a35838496a..7cca51313bd 100644
--- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
+++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
@@ -395,6 +395,7 @@ public static IResourceBuilder WithDataBindMount(this IR
/// The source directory on the host to mount into the container.
/// A flag that indicates if this is a read-only mount.
/// The .
+ [Obsolete("Use WithInitFiles instead.")]
public static IResourceBuilder WithInitBindMount(this IResourceBuilder builder, string source, bool isReadOnly = true)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -403,6 +404,26 @@ public static IResourceBuilder WithInitBindMount(this IR
return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
}
+ ///
+ /// Copies init files to a PostgreSQL container resource.
+ ///
+ /// The resource builder.
+ /// The source directory or files on the host to copy into the container.
+ /// The .
+ public static IResourceBuilder WithInitFiles(this IResourceBuilder builder, string source)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(source);
+
+ const string initPath = "/docker-entrypoint-initdb.d";
+
+ var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);
+
+ return builder.WithContainerFiles(
+ initPath,
+ ContainerDirectory.GetFileSystemItemsFromPath(importFullPath));
+ }
+
///
/// Defines the SQL script used to create the database.
///
diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs
index ed8ce8f63ef..e8ae867511e 100644
--- a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs
@@ -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;
@@ -52,10 +52,17 @@ public string Name
///
public sealed class ContainerFile : ContainerFileSystemItem
{
+
///
- /// 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 . If both are set, an exception will be thrown.
///
public string? Contents { get; set; }
+
+ ///
+ /// 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.
+ /// Setting SourcePath is mutually exclusive with . If both are set, an exception will be thrown.
+ ///
+ public string? SourcePath { get; set; }
}
///
@@ -67,6 +74,110 @@ public sealed class ContainerDirectory : ContainerFileSystemItem
/// The contents of the directory to create in the container. Will create specified and entries in the directory.
///
public IEnumerable Entries { get; set; } = [];
+
+ private class FileTree : Dictionary
+ {
+ public required ContainerFileSystemItem Value { get; set; }
+
+ public static IEnumerable GetItems(KeyValuePair 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}"),
+ };
+ }
+ }
+
+ ///
+ /// Enumerates files from a specified directory and converts them to objects.
+ ///
+ /// The directory path to enumerate files from.
+ ///
+ ///
+ ///
+ /// An enumerable collection of objects.
+ ///
+ /// Thrown when the specified path does not exist.
+ public static IEnumerable 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.");
+ }
}
///
diff --git a/src/Aspire.Hosting/Dcp/Model/Container.cs b/src/Aspire.Hosting/Dcp/Model/Container.cs
index 0e1ca6bf268..7915e3b8048 100644
--- a/src/Aspire.Hosting/Dcp/Model/Container.cs
+++ b/src/Aspire.Hosting/Dcp/Model/Container.cs
@@ -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)
{
@@ -381,7 +387,11 @@ internal sealed class ContainerFileSystemEntry : IEquatable()).SequenceEqual(other.Entries ?? Enumerable.Empty());
}
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs
index da5c0cd7ad5..f258b315a71 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs
@@ -347,9 +347,12 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJson()
await app.StartAsync();
var eventHubsEmulatorResource = builder.Resources.OfType().Single(x => x is { } eventHubsResource && eventHubsResource.IsEmulator);
- var volumeAnnotation = eventHubsEmulatorResource.Annotations.OfType().Single();
+ var configAnnotation = eventHubsEmulatorResource.Annotations.OfType().Single();
- var configJsonContent = File.ReadAllText(volumeAnnotation.Source!);
+ Assert.Equal("/Eventhubs_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = eventHubsEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
Assert.Equal(/*json*/"""
{
@@ -376,7 +379,7 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJson()
}
}
}
- """, configJsonContent);
+ """, configFile.Contents);
await app.StopAsync();
}
@@ -405,19 +408,12 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJsonWithCustomiza
await app.StartAsync();
var eventHubsEmulatorResource = builder.Resources.OfType().Single(x => x is { } eventHubsResource && eventHubsResource.IsEmulator);
- var volumeAnnotation = eventHubsEmulatorResource.Annotations.OfType().Single();
+ var configAnnotation = eventHubsEmulatorResource.Annotations.OfType().Single();
- 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("/Eventhubs_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = eventHubsEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
Assert.Equal(/*json*/"""
{
@@ -441,7 +437,7 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJsonWithCustomiza
},
"Custom": 42
}
- """, configJsonContent);
+ """, configFile.Contents);
await app.StopAsync();
}
@@ -486,13 +482,13 @@ public async Task AzureEventHubsEmulator_WithConfigurationFile()
await app.StartAsync();
var eventHubsEmulatorResource = builder.Resources.OfType().Single(x => x is { } eventHubsResource && eventHubsResource.IsEmulator);
- var volumeAnnotation = eventHubsEmulatorResource.Annotations.OfType().Single();
-
- var configJsonContent = File.ReadAllText(volumeAnnotation.Source!);
-
- Assert.Equal("/Eventhubs_Emulator/ConfigFiles/Config.json", volumeAnnotation.Target);
+ var configAnnotation = eventHubsEmulatorResource.Annotations.OfType().Single();
- Assert.Equal(source, configJsonContent);
+ Assert.Equal("/Eventhubs_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = eventHubsEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
+ Assert.Equal(configJsonPath, configFile.SourcePath);
await app.StopAsync();
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs
index 2df0115900f..747721659b1 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs
@@ -362,19 +362,12 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson()
await app.StartAsync();
var serviceBusEmulatorResource = builder.Resources.OfType().Single(x => x is { } serviceBusResource && serviceBusResource.IsEmulator);
- var volumeAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
+ var configAnnotation = serviceBusEmulatorResource.Annotations.OfType().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("/ServiceBus_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = serviceBusEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
Assert.Equal(/*json*/"""
{
@@ -445,7 +438,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson()
}
}
}
- """, configJsonContent);
+ """, configFile.Contents);
await app.StopAsync();
}
@@ -468,9 +461,12 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedP
await app.StartAsync();
var serviceBusEmulatorResource = builder.Resources.OfType().Single(x => x is { } serviceBusResource && serviceBusResource.IsEmulator);
- var volumeAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
+ var configAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
- var configJsonContent = File.ReadAllText(volumeAnnotation.Source!);
+ Assert.Equal("/ServiceBus_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = serviceBusEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
Assert.Equal("""
{
@@ -494,7 +490,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedP
}
}
}
- """, configJsonContent);
+ """, configFile.Contents);
await app.StopAsync();
}
@@ -521,9 +517,12 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz
await app.StartAsync();
var serviceBusEmulatorResource = builder.Resources.OfType().Single(x => x is { } serviceBusResource && serviceBusResource.IsEmulator);
- var volumeAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
+ var configAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
- var configJsonContent = File.ReadAllText(volumeAnnotation.Source!);
+ Assert.Equal("/ServiceBus_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = serviceBusEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
Assert.Equal("""
{
@@ -541,7 +540,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz
},
"Custom": 42
}
- """, configJsonContent);
+ """, configFile.Contents);
await app.StopAsync();
}
@@ -577,28 +576,14 @@ public async Task AzureServiceBusEmulator_WithConfigurationFile()
using var app = builder.Build();
var serviceBusEmulatorResource = builder.Resources.OfType().Single(x => x is { } serviceBusResource && serviceBusResource.IsEmulator);
- var volumeAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
-
- var configJsonContent = File.ReadAllText(volumeAnnotation.Source!);
+ var configAnnotation = serviceBusEmulatorResource.Annotations.OfType().Single();
- Assert.Equal("/ServiceBus_Emulator/ConfigFiles/Config.json", volumeAnnotation.Target);
+ Assert.Equal("/ServiceBus_Emulator/ConfigFiles", configAnnotation.DestinationPath);
+ var configFiles = await configAnnotation.Callback(new ContainerFileSystemCallbackContext { Model = serviceBusEmulatorResource, ServiceProvider = app.Services }, CancellationToken.None);
+ var configFile = Assert.IsType(Assert.Single(configFiles));
+ Assert.Equal("Config.json", configFile.Name);
- Assert.Equal("""
- {
- "UserConfig": {
- "Namespaces": [
- {
- "Name": "servicebusns",
- "Queues": [ { "Name": "queue456" } ],
- "Topics": []
- }
- ],
- "Logging": {
- "Type": "File"
- }
- }
- }
- """, configJsonContent);
+ Assert.Equal(configJsonPath, configFile.SourcePath);
await app.StopAsync();
diff --git a/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj b/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj
index 2b4fce23954..b92184c8f2c 100644
--- a/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj
+++ b/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj
@@ -27,4 +27,8 @@
+
+
+
+
diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
index 8a069550632..0158dc0634c 100644
--- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
+++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
@@ -37,6 +37,27 @@ public async Task PublishAsync_GeneratesValidDockerComposeFile()
.WithHttpEndpoint(env: "REDIS_PORT")
.WithArgs("-c", "hello $MSG")
.WithEnvironment("MSG", "world")
+ .WithContainerFiles("/usr/local/share", [
+ new ContainerFile
+ {
+ Name = "redis.conf",
+ Contents = "hello world",
+ },
+ new ContainerDirectory
+ {
+ Name = "folder",
+ Entries = [
+ new ContainerFile
+ {
+ Name = "file.sh",
+ SourcePath = "./hello.sh",
+ Owner = 1000,
+ Group = 1000,
+ Mode = UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead,
+ },
+ ],
+ },
+ ])
.WithEnvironment(context =>
{
var resource = (IResourceWithEndpoints)context.Resource;
diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml
index 2bbd74ee3f9..83977135027 100644
--- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml
+++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.yaml
@@ -16,6 +16,15 @@
- "8001:8000"
networks:
- "aspire"
+ configs:
+ - source: "cache__usr_local_share_redis.conf"
+ target: "/usr/local/share/redis.conf"
+ mode: 0644
+ - source: "cache__usr_local_share_folder_file.sh"
+ target: "/usr/local/share/folder/file.sh"
+ uid: "1000"
+ gid: "1000"
+ mode: 0700
something:
image: "dummy/migration:latest"
container_name: "cn"
@@ -54,3 +63,8 @@
networks:
aspire:
driver: "bridge"
+configs:
+ cache__usr_local_share_redis.conf:
+ content: "hello world"
+ cache__usr_local_share_folder_file.sh:
+ file: "cache/hello.sh"
diff --git a/tests/Aspire.Hosting.Docker.Tests/hello.sh b/tests/Aspire.Hosting.Docker.Tests/hello.sh
new file mode 100644
index 00000000000..888763b0865
--- /dev/null
+++ b/tests/Aspire.Hosting.Docker.Tests/hello.sh
@@ -0,0 +1 @@
+echo "Hello World!"
diff --git a/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs b/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs
index da7935ce7a9..43800894bae 100644
--- a/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs
+++ b/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs
@@ -147,11 +147,8 @@ public void WithRealmImportShouldThrowWhenImportDoesNotExist()
Assert.Throws(action);
}
- [Theory]
- [InlineData(null)]
- [InlineData(true)]
- [InlineData(false)]
- public void WithRealmImportDirectoryAddsBindMountAnnotation(bool? isReadOnly)
+ [Fact]
+ public async Task WithRealmImportDirectoryAddsContainerFilesAnnotation()
{
using var builder = TestDistributedApplicationBuilder.Create();
@@ -161,28 +158,23 @@ public void WithRealmImportDirectoryAddsBindMountAnnotation(bool? isReadOnly)
var resourceName = "keycloak";
var keycloak = builder.AddKeycloak(resourceName);
- if (isReadOnly.HasValue)
- {
- keycloak.WithRealmImport(tempDirectory, isReadOnly: isReadOnly.Value);
- }
- else
- {
- keycloak.WithRealmImport(tempDirectory);
- }
-
- var containerAnnotation = keycloak.Resource.Annotations.OfType().Single();
-
- 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);
+ keycloak.WithRealmImport(tempDirectory);
+
+ using var app = builder.Build();
+ var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal));
+
+ var containerAnnotation = keycloak.Resource.Annotations.OfType().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(entries.First());
+ Assert.Equal("import", importDirectory.Name);
+ Assert.Empty(importDirectory.Entries);
}
- [Theory]
- [InlineData(null)]
- [InlineData(true)]
- [InlineData(false)]
- public void WithRealmImportFileAddsBindMountAnnotation(bool? isReadOnly)
+ [Fact]
+ public async Task WithRealmImportFileAddsContainerFilesAnnotation()
{
using var builder = TestDistributedApplicationBuilder.Create();
@@ -196,20 +188,20 @@ public void WithRealmImportFileAddsBindMountAnnotation(bool? isReadOnly)
var resourceName = "keycloak";
var keycloak = builder.AddKeycloak(resourceName);
- if (isReadOnly.HasValue)
- {
- keycloak.WithRealmImport(filePath, isReadOnly: isReadOnly.Value);
- }
- else
- {
- keycloak.WithRealmImport(filePath);
- }
-
- var containerAnnotation = keycloak.Resource.Annotations.OfType().Single();
-
- 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);
+ keycloak.WithRealmImport(filePath);
+
+ using var app = builder.Build();
+ var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal));
+
+ var containerAnnotation = keycloak.Resource.Annotations.OfType().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(entries.First());
+ Assert.Equal("import", importDirectory.Name);
+ var realmFile = Assert.IsType(Assert.Single(importDirectory.Entries));
+ Assert.Equal(file, realmFile.Name);
+ Assert.Equal(filePath, realmFile.SourcePath);
}
}
diff --git a/tests/Aspire.Hosting.Milvus.Tests/MilvusPublicApiTests.cs b/tests/Aspire.Hosting.Milvus.Tests/MilvusPublicApiTests.cs
index b33dc91345b..e3c37345e88 100644
--- a/tests/Aspire.Hosting.Milvus.Tests/MilvusPublicApiTests.cs
+++ b/tests/Aspire.Hosting.Milvus.Tests/MilvusPublicApiTests.cs
@@ -138,7 +138,9 @@ public void WithConfigurationBindMountShouldThrowWhenBuilderIsNull()
IResourceBuilder builder = null!;
const string configurationFilePath = "/milvus/configs/milvus.yaml";
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithConfigurationBindMount(configurationFilePath);
+#pragma warning restore CS0618 // Type or member is obsolete
var exception = Assert.Throws(action);
Assert.Equal(nameof(builder), exception.ParamName);
@@ -153,7 +155,38 @@ public void WithConfigurationBindMountShouldThrowWhenConfigurationFilePathIsNull
.AddMilvus("Milvus");
string configurationFilePath = isNull ? null! : string.Empty;
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithConfigurationBindMount(configurationFilePath);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(configurationFilePath), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithConfigurationFileShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ const string configurationFilePath = "/milvus/configs/milvus.yaml";
+
+ var action = () => builder.WithConfigurationFile(configurationFilePath);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithConfigurationFileShouldThrowWhenConfigurationFilePathIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.Create()
+ .AddMilvus("Milvus");
+ string configurationFilePath = isNull ? null! : string.Empty;
+
+ var action = () => builder.WithConfigurationFile(configurationFilePath);
var exception = isNull
? Assert.Throws(action)
diff --git a/tests/Aspire.Hosting.MongoDB.Tests/MongoDBPublicApiTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/MongoDBPublicApiTests.cs
index 92b09620bee..0bbd9a71058 100644
--- a/tests/Aspire.Hosting.MongoDB.Tests/MongoDBPublicApiTests.cs
+++ b/tests/Aspire.Hosting.MongoDB.Tests/MongoDBPublicApiTests.cs
@@ -169,7 +169,9 @@ public void WithInitBindMountShouldThrowWhenBuilderIsNull()
{
IResourceBuilder builder = null!;
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount("init.js");
+#pragma warning restore CS0618 // Type or member is obsolete
var exception = Assert.Throws(action);
Assert.Equal(nameof(builder), exception.ParamName);
@@ -184,7 +186,37 @@ public void WithInitBindMountShouldThrowWhenSourceIsNullOrEmpty(bool isNull)
.AddMongoDB("MongoDB");
var source = isNull ? null! : string.Empty;
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(source), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithInitFilesShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithInitFiles("init.js");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithInitFilesShouldThrowWhenSourceIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.Create()
+ .AddMongoDB("MongoDB");
+ var source = isNull ? null! : string.Empty;
+
+ var action = () => builder.WithInitFiles(source);
var exception = isNull
? Assert.Throws(action)
diff --git a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs
index 5d7d61521df..b9b7a50c448 100644
--- a/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs
+++ b/tests/Aspire.Hosting.MongoDB.Tests/MongoDbFunctionalTests.cs
@@ -291,8 +291,10 @@ await File.WriteAllTextAsync(initFilePath, $$"""
using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+#pragma warning disable CS0618 // Type or member is obsolete
var mongodb = builder.AddMongoDB("mongodb")
.WithInitBindMount(bindMountPath);
+#pragma warning restore CS0618 // Type or member is obsolete
var db = mongodb.AddDatabase(dbName);
using var app = builder.Build();
@@ -340,6 +342,97 @@ await pipeline.ExecuteAsync(async token =>
}
}
+ [Fact]
+ [RequiresDocker]
+ [QuarantinedTest("https://github.com/dotnet/aspire/issues/5937")]
+ public async Task VerifyWithInitFiles()
+ {
+ // Creates a script that should be executed when the container is initialized.
+
+ var dbName = "testdb";
+
+ var cts = new CancellationTokenSource(TimeSpan.FromMinutes(6));
+ var pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) })
+ .Build();
+
+ var initFilesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ try
+ {
+ var initFilePath = Path.Combine(initFilesPath, "mongo-init.js");
+ await File.WriteAllTextAsync(initFilePath, $$"""
+ db = db.getSiblingDB('{{dbName}}');
+
+ db.createCollection('{{CollectionName}}');
+
+ db.{{CollectionName}}.insertMany([
+ {
+ name: 'The Shawshank Redemption'
+ },
+ {
+ name: 'The Godfather'
+ },
+ {
+ name: 'The Dark Knight'
+ },
+ {
+ name: 'Schindler\'s List'
+ }
+ ]);
+ """);
+
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+
+ var mongodb = builder.AddMongoDB("mongodb")
+ .WithInitFiles(initFilesPath);
+
+ var db = mongodb.AddDatabase(dbName);
+ using var app = builder.Build();
+
+ await app.StartAsync();
+
+ var hb = Host.CreateApplicationBuilder();
+
+ hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default);
+
+ hb.AddMongoDBClient(db.Resource.Name);
+
+ using var host = hb.Build();
+
+ await host.StartAsync();
+
+ var mongoDatabase = host.Services.GetRequiredService();
+
+ await pipeline.ExecuteAsync(async token =>
+ {
+ var mongoDatabase = host.Services.GetRequiredService();
+
+ var collection = mongoDatabase.GetCollection(CollectionName);
+
+ var results = await collection.Find(new BsonDocument()).ToListAsync(token);
+
+ Assert.Collection(results,
+ item => Assert.Contains("The Shawshank Redemption", item.Name),
+ item => Assert.Contains("The Godfather", item.Name),
+ item => Assert.Contains("The Dark Knight", item.Name),
+ item => Assert.Contains("Schindler's List", item.Name)
+ );
+ }, cts.Token);
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(initFilesPath);
+ }
+ catch
+ {
+ // Don't fail test if we can't clean the temporary folder
+ }
+ }
+ }
+
private static async Task CreateTestDataAsync(IMongoDatabase mongoDatabase, CancellationToken token)
{
await mongoDatabase.CreateCollectionAsync(CollectionName, cancellationToken: token);
diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs
index 0cef5868b28..ed67776b318 100644
--- a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs
+++ b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs
@@ -320,7 +320,9 @@ public async Task VerifyWithInitBindMount()
var mysql = builder.AddMySql("mysql").WithEnvironment("MYSQL_DATABASE", mySqlDbName);
var db = mysql.AddDatabase(mySqlDbName);
+#pragma warning disable CS0618 // Type or member is obsolete
mysql.WithInitBindMount(bindMountPath);
+#pragma warning restore CS0618 // Type or member is obsolete
using var app = builder.Build();
@@ -376,6 +378,91 @@ await pipeline.ExecuteAsync(async token =>
}
}
+ [Fact]
+ [RequiresDocker]
+ public async Task VerifyWithInitFiles()
+ {
+ // Creates a script that should be executed when the container is initialized.
+
+ using var cts = new CancellationTokenSource(TestConstants.ExtraLongTimeoutTimeSpan * 2);
+ var pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2), ShouldHandle = new PredicateBuilder().Handle() })
+ .Build();
+
+ var initFilesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ Directory.CreateDirectory(initFilesPath);
+
+ try
+ {
+ File.WriteAllText(Path.Combine(initFilesPath, "init.sql"), """
+ CREATE TABLE cars (brand VARCHAR(255));
+ INSERT INTO cars (brand) VALUES ('BatMobile');
+ """);
+
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+
+ var mySqlDbName = "db1";
+
+ var mysql = builder.AddMySql("mysql").WithEnvironment("MYSQL_DATABASE", mySqlDbName);
+ var db = mysql.AddDatabase(mySqlDbName);
+
+ mysql.WithInitFiles(initFilesPath);
+
+ using var app = builder.Build();
+
+ await app.StartAsync(cts.Token);
+
+ await app.WaitForTextAsync(s_mySqlReadyText, cts.Token).WaitAsync(cts.Token);
+
+ var hb = Host.CreateApplicationBuilder();
+
+ hb.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ [$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(cts.Token)
+ });
+
+ hb.AddMySqlDataSource(db.Resource.Name);
+
+ using var host = hb.Build();
+
+ await host.StartAsync(cts.Token);
+
+ // Wait until the database is available
+ await pipeline.ExecuteAsync(async token =>
+ {
+ using var connection = host.Services.GetRequiredService();
+ await connection.OpenAsync(token);
+ Assert.Equal(ConnectionState.Open, connection.State);
+ }, cts.Token);
+
+ await pipeline.ExecuteAsync(async token =>
+ {
+ using var connection = host.Services.GetRequiredService();
+ await connection.OpenAsync(token);
+
+ var command = connection.CreateCommand();
+ command.CommandText = $"SELECT * FROM cars;";
+
+ var results = await command.ExecuteReaderAsync(token);
+ Assert.True(await results.ReadAsync(token));
+ Assert.Equal("BatMobile", results.GetString("brand"));
+ Assert.False(await results.ReadAsync(token));
+ }, cts.Token);
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(initFilesPath);
+ }
+ catch
+ {
+ // Don't fail test if we can't clean the temporary folder
+ }
+ }
+ }
+
[Fact]
[RequiresDocker]
public async Task VerifyEfMySql()
diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlPublicApiTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlPublicApiTests.cs
index 86e5e4e67e2..02bf59a8ebd 100644
--- a/tests/Aspire.Hosting.MySql.Tests/MySqlPublicApiTests.cs
+++ b/tests/Aspire.Hosting.MySql.Tests/MySqlPublicApiTests.cs
@@ -137,7 +137,9 @@ public void WithInitBindMountShouldThrowWhenBuilderIsNull()
IResourceBuilder builder = null!;
const string source = "/MySql/init.sql";
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
var exception = Assert.Throws(action);
Assert.Equal(nameof(builder), exception.ParamName);
@@ -152,7 +154,38 @@ public void WithInitBindMountShouldThrowWhenSourceIsNullOrEmpty(bool isNull)
.AddMySql("MySql");
var source = isNull ? null! : string.Empty;
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(source), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithInitFilesShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ const string source = "/MySql/init.sql";
+
+ var action = () => builder.WithInitFiles(source);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithInitFilesShouldThrowWhenSourceIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.Create()
+ .AddMySql("MySql");
+ var source = isNull ? null! : string.Empty;
+
+ var action = () => builder.WithInitFiles(source);
var exception = isNull
? Assert.Throws(action)
diff --git a/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs b/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs
index 5b19d5b3539..3a534764f5f 100644
--- a/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs
+++ b/tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs
@@ -242,11 +242,9 @@ await pipeline.ExecuteAsync(async token =>
}
}
- [Theory]
- [InlineData(true)]
- [InlineData(false, Skip = "https://github.com/dotnet/aspire/issues/5190")]
+ [Fact]
[RequiresDocker]
- public async Task VerifyWithInitBindMount(bool init)
+ public async Task VerifyWithInitBindMount()
{
// Creates a script that should be executed when the container is initialized.
@@ -288,13 +286,105 @@ public async Task VerifyWithInitBindMount(bool init)
var ready = builder;
+#pragma warning disable CS0618 // Type or member is obsolete
+ oracle.WithInitBindMount(bindMountPath);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ using var app = builder.Build();
+
+ await app.StartAsync();
+
+ await app.WaitForTextAsync(DatabaseReadyText, cancellationToken: cts.Token);
+
+ var hb = Host.CreateApplicationBuilder();
+
+ hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default);
+
+ hb.AddOracleDatabaseDbContext(db.Resource.Name);
+
+ using var host = hb.Build();
+
+ try
+ {
+ await host.StartAsync();
+
+ var dbContext = host.Services.GetRequiredService();
+
+ // Wait until the database is available
+ await pipeline.ExecuteAsync(async token =>
+ {
+ return await dbContext.Database.CanConnectAsync(token);
+ }, cts.Token);
+
+ var brands = await dbContext.Cars.ToListAsync(cancellationToken: cts.Token);
+ Assert.Single(brands);
+ Assert.Equal("BatMobile", brands[0].Brand);
+ }
+ finally
+ {
+ await app.StopAsync();
+ }
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(bindMountPath, true);
+ }
+ catch
+ {
+ // Don't fail test if we can't clean the temporary folder
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false, Skip = "https://github.com/dotnet/aspire/issues/5190")]
+ [RequiresDocker]
+ public async Task VerifyWithInitFiles(bool init)
+ {
+ // Creates a script that should be executed when the container is initialized.
+
+ var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
+ var pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new()
+ {
+ MaxRetryAttempts = int.MaxValue,
+ BackoffType = DelayBackoffType.Linear,
+ ShouldHandle = new PredicateBuilder().HandleResult(false),
+ Delay = TimeSpan.FromSeconds(2)
+ })
+ .Build();
+
+ var initFilesPath = Directory.CreateTempSubdirectory().FullName;
+
+ var oracleDbName = "freepdb1";
+
+ try
+ {
+ File.WriteAllText(Path.Combine(initFilesPath, "01_init.sql"), $"""
+ ALTER SESSION SET CONTAINER={oracleDbName};
+ ALTER SESSION SET CURRENT_SCHEMA = SYSTEM;
+ CREATE TABLE "Cars" ("Id" NUMBER(10) GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL, "Brand" NVARCHAR2(2000) NOT NULL, CONSTRAINT "PK_Cars" PRIMARY KEY ("Id") );
+ INSERT INTO "Cars" ("Id", "Brand") VALUES (1, 'BatMobile');
+ COMMIT;
+ """);
+
+ using var builder = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper);
+
+ var oracle = builder.AddOracle("oracle");
+ var db = oracle.AddDatabase(oracleDbName);
+
+ var ready = builder;
+
if (init)
{
- oracle.WithInitBindMount(bindMountPath);
+ oracle.WithInitFiles(initFilesPath);
}
else
{
- oracle.WithDbSetupBindMount(bindMountPath);
+ oracle.WithDbSetupBindMount(initFilesPath);
}
using var app = builder.Build();
@@ -336,7 +426,7 @@ await pipeline.ExecuteAsync(async token =>
{
try
{
- Directory.Delete(bindMountPath, true);
+ Directory.Delete(initFilesPath, true);
}
catch
{
diff --git a/tests/Aspire.Hosting.Oracle.Tests/OraclePublicApiTests.cs b/tests/Aspire.Hosting.Oracle.Tests/OraclePublicApiTests.cs
index dcfebaba153..4ae94f6c1ba 100644
--- a/tests/Aspire.Hosting.Oracle.Tests/OraclePublicApiTests.cs
+++ b/tests/Aspire.Hosting.Oracle.Tests/OraclePublicApiTests.cs
@@ -112,7 +112,9 @@ public void WithInitBindMountShouldThrowWhenBuilderIsNull()
IResourceBuilder builder = null!;
const string source = "/opt/oracle/scripts/startup";
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
var exception = Assert.Throws(action);
Assert.Equal(nameof(builder), exception.ParamName);
@@ -127,7 +129,38 @@ public void WithInitBindMountShouldThrowWhenNameIsNullOrEmpty(bool isNull)
.AddOracle("oracle");
var source = isNull ? null! : string.Empty;
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(source), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithInitFilesShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ const string source = "/opt/oracle/scripts/startup";
+
+ var action = () => builder.WithInitFiles(source);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithInitFilesShouldThrowWhenNameIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.Create()
+ .AddOracle("oracle");
+ var source = isNull ? null! : string.Empty;
+
+ var action = () => builder.WithInitFiles(source);
var exception = isNull
? Assert.Throws(action)
diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgrePublicApiTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgrePublicApiTests.cs
index 4f4916355a1..be64476ff7b 100644
--- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgrePublicApiTests.cs
+++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgrePublicApiTests.cs
@@ -189,7 +189,21 @@ public void WithInitBindMountShouldThrowWhenBuilderIsNull()
IResourceBuilder builder = null!;
const string source = "/docker-entrypoint-initdb.d";
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithInitFilesShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ const string source = "/docker-entrypoint-initdb.d";
+
+ var action = () => builder.WithInitFiles(source);
var exception = Assert.Throws(action);
Assert.Equal(nameof(builder), exception.ParamName);
@@ -204,7 +218,26 @@ public void WithInitBindMountShouldThrowWhenSourceIsNullOrEmpty(bool isNull)
.AddPostgres("Postgres");
var source = isNull ? null! : string.Empty;
+#pragma warning disable CS0618 // Type or member is obsolete
var action = () => builder.WithInitBindMount(source);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(source), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithInitFilesShouldThrowWhenSourceIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.Create()
+ .AddPostgres("Postgres");
+ var source = isNull ? null! : string.Empty;
+
+ var action = () => builder.WithInitFiles(source);
var exception = isNull
? Assert.Throws(action)
diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs
index 51b60953286..8303ffcdc00 100644
--- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs
+++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs
@@ -376,14 +376,17 @@ 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);
var db = postgres.AddDatabase(postgresDbName);
+#pragma warning disable CS0618 // Type or member is obsolete
postgres.WithInitBindMount(bindMountPath);
+#pragma warning restore CS0618 // Type or member is obsolete
using var app = builder.Build();
@@ -439,6 +442,92 @@ await pipeline.ExecuteAsync(async token =>
}
}
+ [Fact]
+ [RequiresDocker]
+ public async Task VerifyWithInitFiles()
+ {
+ // Creates a script that should be executed when the container is initialized.
+
+ var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ var pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new() { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(2) })
+ .Build();
+
+ var initFilesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ Directory.CreateDirectory(initFilesPath);
+
+ try
+ {
+ File.WriteAllText(Path.Combine(initFilesPath, "init.sql"), """
+ CREATE TABLE "Cars" (brand VARCHAR(255));
+ INSERT INTO "Cars" (brand) VALUES ('BatMobile');
+ """);
+
+ using var builder = TestDistributedApplicationBuilder
+ .CreateWithTestContainerRegistry(testOutputHelper);
+
+ var postgresDbName = "db1";
+ var postgres = builder.AddPostgres("pg").WithEnvironment("POSTGRES_DB", postgresDbName);
+
+ var db = postgres.AddDatabase(postgresDbName);
+
+ postgres.WithInitFiles(initFilesPath);
+
+ using var app = builder.Build();
+
+ await app.StartAsync();
+
+ var hb = Host.CreateApplicationBuilder();
+
+ hb.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ [$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default)
+ });
+
+ hb.AddNpgsqlDataSource(db.Resource.Name);
+
+ using var host = hb.Build();
+
+ await host.StartAsync();
+
+ await app.ResourceNotifications.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token);
+
+ // Wait until the database is available
+ await pipeline.ExecuteAsync(async token =>
+ {
+ using var connection = host.Services.GetRequiredService();
+ await connection.OpenAsync(token);
+ Assert.Equal(ConnectionState.Open, connection.State);
+ }, cts.Token);
+
+ await pipeline.ExecuteAsync(async token =>
+ {
+ using var connection = host.Services.GetRequiredService();
+ await connection.OpenAsync(token);
+
+ using var command = connection.CreateCommand();
+ command.CommandText = $"SELECT * FROM \"Cars\";";
+ using var results = await command.ExecuteReaderAsync(token);
+
+ Assert.True(await results.ReadAsync(token));
+ Assert.Equal("BatMobile", results.GetString("brand"));
+ Assert.False(await results.ReadAsync(token));
+ }, cts.Token);
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(initFilesPath, true);
+ }
+ catch
+ {
+ // Don't fail test if we can't clean the temporary folder
+ }
+ }
+ }
+
[Fact]
[RequiresDocker]
public async Task Postgres_WithPersistentLifetime_ReusesContainers()
diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs
index d74f5339e72..63c4701a098 100644
--- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs
+++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs
@@ -472,6 +472,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,
+ },
],
},
};