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, + }, ], }, };