From fd41c81abcdf10661edc210678507be99e5790f8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 21 Apr 2025 12:21:11 -0700 Subject: [PATCH 01/11] New DCP create files schema --- .../ContainerFileSystemCallbackAnnotation.cs | 8 +++++++- src/Aspire.Hosting/Dcp/Model/Container.cs | 13 ++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs index ed8ce8f63ef..e435c559044 100644 --- a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs @@ -53,9 +53,15 @@ 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; } } /// 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()); } From 9471516cfdcdc550fb5f4b9bc83a3b9848c80e4f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 21 Apr 2025 16:21:43 -0700 Subject: [PATCH 02/11] Update keycloak realm init to use WithContainerFiles --- .../KeycloakResourceBuilderExtensions.cs | 35 +++++++++++++++--- .../KeycloakPublicApiTests.cs | 36 ++++++++++++------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs index 773b514e068..0374931f11d 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. @@ -163,14 +164,38 @@ public static IResourceBuilder WithRealmImport( if (Directory.Exists(importFullPath)) { - return builder.WithBindMount(importFullPath, RealmImportDirectory, isReadOnly); + return builder.WithContainerFiles( + KeycloakDataDirectory, + [ + new ContainerDirectory + { + Name = "import", + Entries = Directory.GetFiles(importFullPath) + .Select(file => new ContainerFile{ + Name = Path.GetFileName(file), + SourcePath = file, + }), + }, + ]); } if (File.Exists(importFullPath)) { - var fileName = Path.GetFileName(import); - - return builder.WithBindMount(importFullPath, $"{RealmImportDirectory}/{fileName}", isReadOnly); + return builder.WithContainerFiles( + KeycloakDataDirectory, + [ + new ContainerDirectory + { + Name = "import", + Entries = [ + new ContainerFile + { + Name = Path.GetFileName(importFullPath), + SourcePath = importFullPath, + }, + ], + }, + ]); } throw new InvalidOperationException($"The realm import file or directory '{importFullPath}' does not exist."); diff --git a/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs b/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs index da7935ce7a9..101c109b6fc 100644 --- a/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs +++ b/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs @@ -151,7 +151,7 @@ public void WithRealmImportShouldThrowWhenImportDoesNotExist() [InlineData(null)] [InlineData(true)] [InlineData(false)] - public void WithRealmImportDirectoryAddsBindMountAnnotation(bool? isReadOnly) + public async Task WithRealmImportDirectoryAddsContainerFilesAnnotation(bool? isReadOnly) { using var builder = TestDistributedApplicationBuilder.Create(); @@ -170,19 +170,24 @@ public void WithRealmImportDirectoryAddsBindMountAnnotation(bool? isReadOnly) keycloak.WithRealmImport(tempDirectory); } - var containerAnnotation = keycloak.Resource.Annotations.OfType().Single(); + using var app = builder.Build(); + var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal)); - Assert.Equal(tempDirectory, containerAnnotation.Source); - Assert.Equal("/opt/keycloak/data/import", containerAnnotation.Target); - Assert.Equal(ContainerMountType.BindMount, containerAnnotation.Type); - Assert.Equal(isReadOnly ?? false, containerAnnotation.IsReadOnly); + var containerAnnotation = keycloak.Resource.Annotations.OfType().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) + public async Task WithRealmImportFileAddsContainerFilesAnnotation(bool? isReadOnly) { using var builder = TestDistributedApplicationBuilder.Create(); @@ -205,11 +210,18 @@ public void WithRealmImportFileAddsBindMountAnnotation(bool? isReadOnly) keycloak.WithRealmImport(filePath); } - var containerAnnotation = keycloak.Resource.Annotations.OfType().Single(); + 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(filePath, containerAnnotation.Source); - Assert.Equal($"/opt/keycloak/data/import/{file}", containerAnnotation.Target); - Assert.Equal(ContainerMountType.BindMount, containerAnnotation.Type); - Assert.Equal(isReadOnly ?? false, containerAnnotation.IsReadOnly); + Assert.Equal("/opt/keycloak/data", containerAnnotation.DestinationPath); + var importDirectory = Assert.IsType(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); } } From f27d19eea5f8b81499ebfa83b9d2a8d55e3f35be Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 24 Apr 2025 10:13:09 -0700 Subject: [PATCH 03/11] Update tests, add helpers, cover additional scenarios --- .../KeycloakResourceBuilderExtensions.cs | 48 ++------ .../PostgresBuilderExtensions.cs | 8 +- .../ContainerFileSystemCallbackAnnotation.cs | 107 +++++++++++++++++- .../PostgresFunctionalTests.cs | 3 +- .../DistributedApplicationTests.cs | 6 + 5 files changed, 132 insertions(+), 40 deletions(-) diff --git a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs index 0374931f11d..008ccc37c4e 100644 --- a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs @@ -162,42 +162,16 @@ public static IResourceBuilder WithRealmImport( var importFullPath = Path.GetFullPath(import, builder.ApplicationBuilder.AppHostDirectory); - if (Directory.Exists(importFullPath)) - { - return builder.WithContainerFiles( - KeycloakDataDirectory, - [ - new ContainerDirectory - { - Name = "import", - Entries = Directory.GetFiles(importFullPath) - .Select(file => new ContainerFile{ - Name = Path.GetFileName(file), - SourcePath = file, - }), - }, - ]); - } - - if (File.Exists(importFullPath)) - { - return builder.WithContainerFiles( - KeycloakDataDirectory, - [ - new ContainerDirectory - { - Name = "import", - Entries = [ - new ContainerFile - { - Name = Path.GetFileName(importFullPath), - SourcePath = importFullPath, - }, - ], - }, - ]); - } - - 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.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 4a35838496a..88b54fef57d 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -400,7 +400,13 @@ public static IResourceBuilder WithInitBindMount(this IR ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(source); - return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly); + const string initPath = "/docker-entrypoint-initdb.d"; + + var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory); + + return builder.WithContainerFiles( + initPath, + ContainerDirectory.GetFileSystemItemsFromPath(importFullPath)); } /// diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFileSystemCallbackAnnotation.cs index e435c559044..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,6 +52,7 @@ public string Name /// public sealed class ContainerFile : ContainerFileSystemItem { + /// /// The contents of the file. Setting Contents is mutually exclusive with . If both are set, an exception will be thrown. /// @@ -73,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/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs index 51b60953286..24a208c26da 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -376,7 +376,8 @@ public async Task VerifyWithInitBindMount() INSERT INTO "Cars" (brand) VALUES ('BatMobile'); """); - using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + using var builder = TestDistributedApplicationBuilder + .CreateWithTestContainerRegistry(testOutputHelper); var postgresDbName = "db1"; var postgres = builder.AddPostgres("pg").WithEnvironment("POSTGRES_DB", postgresDbName); diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 73f6c3ee459..934cf77166d 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -448,6 +448,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, + }, ], }, }; From 8a165da4855690133df43221e797fadb2b4d04e7 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 25 Apr 2025 10:24:42 -0700 Subject: [PATCH 04/11] Update more usage of BindMounts for config, make old methods Obsolete --- .../AzureServiceBusEmulatorResource.cs | 4 +- .../AzureServiceBusExtensions.cs | 123 +++++++----------- .../ConfigFileAnnotation.cs | 19 +++ .../KeycloakResourceBuilderExtensions.cs | 28 +++- .../api/Aspire.Hosting.Keycloak.cs | 4 +- .../MilvusBuilderExtensions.cs | 25 +++- .../api/Aspire.Hosting.Milvus.cs | 2 + .../MongoDBBuilderExtensions.cs | 21 +++ .../api/Aspire.Hosting.MongoDB.cs | 2 + .../MySqlBuilderExtensions.cs | 21 +++ .../api/Aspire.Hosting.MySql.cs | 2 + .../OracleDatabaseBuilderExtensions.cs | 21 +++ .../api/Aspire.Hosting.Oracle.cs | 2 + .../PostgresBuilderExtensions.cs | 15 +++ .../api/Aspire.Hosting.PostgreSQL.cs | 2 + .../AzureServiceBusExtensionsTests.cs | 63 ++++----- .../KeycloakPublicApiTests.cs | 32 +---- .../MilvusPublicApiTests.cs | 33 +++++ .../MongoDBPublicApiTests.cs | 32 +++++ .../MongoDbFunctionalTests.cs | 93 +++++++++++++ .../MySqlFunctionalTests.cs | 87 +++++++++++++ .../MySqlPublicApiTests.cs | 33 +++++ .../OracleFunctionalTests.cs | 104 ++++++++++++++- .../OraclePublicApiTests.cs | 33 +++++ .../PostgrePublicApiTests.cs | 33 +++++ .../PostgresFunctionalTests.cs | 88 +++++++++++++ 26 files changed, 768 insertions(+), 154 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.ServiceBus/ConfigFileAnnotation.cs 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 174999ac8a9..d9b44b16916 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; @@ -21,8 +22,6 @@ namespace Aspire.Hosting; /// public static class AzureServiceBusExtensions { - private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; - /// /// Adds an Azure Service Bus Namespace resource to the application model. This resource can be used to create queue, topic, and subscription resources. /// @@ -31,7 +30,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 . @@ -387,10 +386,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)) @@ -403,77 +403,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; - }); + ); ServiceBusClient? serviceBusClient = null; string? queueOrTopicName = null; @@ -519,7 +498,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. @@ -529,14 +508,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); } /// @@ -585,12 +557,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(); // { @@ -649,7 +618,9 @@ private static string WriteEmulatorConfigJson(AzureServiceBusResource emulatorRe writer.WriteEndObject(); // } (/UserConfig) writer.WriteEndObject(); // } (/Root) - return filePath; + writer.Flush(); + + return Encoding.UTF8.GetString(stream.ToArray()); } /// @@ -666,7 +637,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.Keycloak/KeycloakResourceBuilderExtensions.cs b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs index 008ccc37c4e..d72dd51c196 100644 --- a/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Keycloak/KeycloakResourceBuilderExtensions.cs @@ -143,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 /// @@ -152,10 +152,34 @@ 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); diff --git a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs index ba14cece1c3..bfe5d23b581 100644 --- a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs +++ b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs @@ -16,7 +16,9 @@ public static partial class KeycloakResourceBuilderExtensions public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import, bool isReadOnly = false) { throw null; } + public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import, bool isReadOnly) { throw null; } + + public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import) { throw null; } } } 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.Milvus/api/Aspire.Hosting.Milvus.cs b/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs index edd18841978..aadec1bec18 100644 --- a/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs +++ b/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs @@ -19,6 +19,8 @@ public static ApplicationModel.IResourceBuilder WithAttu(this ApplicationM public static ApplicationModel.IResourceBuilder WithConfigurationBindMount(this ApplicationModel.IResourceBuilder builder, string configurationFilePath) { throw null; } + public static ApplicationModel.IResourceBuilder WithConfigurationFile(this ApplicationModel.IResourceBuilder builder, string configurationFilePath) { throw null; } + public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } 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.MongoDB/api/Aspire.Hosting.MongoDB.cs b/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs index 849ec42a5f1..d9ba8cde863 100644 --- a/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs +++ b/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs @@ -24,6 +24,8 @@ public static partial class MongoDBBuilderExtensions public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } + public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } + public static ApplicationModel.IResourceBuilder WithMongoExpress(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.MongoDBServerResource { throw null; } } diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs index 8d371f12a6a..8985b94bc6d 100644 --- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs +++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs @@ -224,6 +224,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); @@ -232,6 +233,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.MySql/api/Aspire.Hosting.MySql.cs b/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs index 91654c0dc5d..78eb1b19de1 100644 --- a/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs +++ b/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs @@ -22,6 +22,8 @@ public static partial class MySqlBuilderExtensions public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } + public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } + public static ApplicationModel.IResourceBuilder WithPhpMyAdmin(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.MySqlServerResource { throw null; } } 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.Oracle/api/Aspire.Hosting.Oracle.cs b/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs index 4f1c3e96376..62db80ebf80 100644 --- a/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs +++ b/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs @@ -21,6 +21,8 @@ public static partial class OracleDatabaseBuilderExtensions public static ApplicationModel.IResourceBuilder WithDbSetupBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } + + public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } } } diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 88b54fef57d..7cca51313bd 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -395,11 +395,26 @@ 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); ArgumentException.ThrowIfNullOrEmpty(source); + 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); diff --git a/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs b/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs index f49cf4ef742..950dd2dc090 100644 --- a/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs +++ b/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs @@ -26,6 +26,8 @@ public static partial class PostgresBuilderExtensions public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } + public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } + public static ApplicationModel.IResourceBuilder WithPgAdmin(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.PostgresServerResource { throw null; } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index dc2e44c5ecd..8f379689718 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -444,19 +444,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*/""" { @@ -527,7 +520,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() } } } - """, configJsonContent); + """, configFile.Contents); await app.StopAsync(); } @@ -550,9 +543,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(""" { @@ -576,7 +572,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedP } } } - """, configJsonContent); + """, configFile.Contents); await app.StopAsync(); } @@ -603,9 +599,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(""" { @@ -623,7 +622,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz }, "Custom": 42 } - """, configJsonContent); + """, configFile.Contents); await app.StopAsync(); } @@ -659,28 +658,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.Keycloak.Tests/KeycloakPublicApiTests.cs b/tests/Aspire.Hosting.Keycloak.Tests/KeycloakPublicApiTests.cs index 101c109b6fc..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 async Task WithRealmImportDirectoryAddsContainerFilesAnnotation(bool? isReadOnly) + [Fact] + public async Task WithRealmImportDirectoryAddsContainerFilesAnnotation() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -161,14 +158,7 @@ public async Task WithRealmImportDirectoryAddsContainerFilesAnnotation(bool? isR var resourceName = "keycloak"; var keycloak = builder.AddKeycloak(resourceName); - if (isReadOnly.HasValue) - { - keycloak.WithRealmImport(tempDirectory, isReadOnly: isReadOnly.Value); - } - else - { - keycloak.WithRealmImport(tempDirectory); - } + keycloak.WithRealmImport(tempDirectory); using var app = builder.Build(); var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal)); @@ -183,11 +173,8 @@ public async Task WithRealmImportDirectoryAddsContainerFilesAnnotation(bool? isR Assert.Empty(importDirectory.Entries); } - [Theory] - [InlineData(null)] - [InlineData(true)] - [InlineData(false)] - public async Task WithRealmImportFileAddsContainerFilesAnnotation(bool? isReadOnly) + [Fact] + public async Task WithRealmImportFileAddsContainerFilesAnnotation() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -201,14 +188,7 @@ public async Task WithRealmImportFileAddsContainerFilesAnnotation(bool? isReadOn var resourceName = "keycloak"; var keycloak = builder.AddKeycloak(resourceName); - if (isReadOnly.HasValue) - { - keycloak.WithRealmImport(filePath, isReadOnly: isReadOnly.Value); - } - else - { - keycloak.WithRealmImport(filePath); - } + keycloak.WithRealmImport(filePath); using var app = builder.Build(); var keycloakResource = builder.Resources.Single(r => r.Name.Equals(resourceName, StringComparison.Ordinal)); 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 4812fd1747c..6b818c9aa04 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 cd173fab18a..ddd156fbb1f 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 24a208c26da..8303ffcdc00 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresFunctionalTests.cs @@ -384,7 +384,9 @@ public async Task VerifyWithInitBindMount() 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(); @@ -440,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() From 53b68131728e62f82c32b5262e48bd2993170834 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 25 Apr 2025 11:20:34 -0700 Subject: [PATCH 05/11] Don't update generated files --- src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs | 2 -- src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs | 2 -- src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs | 2 -- src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs | 2 -- src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs | 2 -- src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs | 2 -- 6 files changed, 12 deletions(-) diff --git a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs index bfe5d23b581..a42772146aa 100644 --- a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs +++ b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs @@ -17,8 +17,6 @@ public static partial class KeycloakResourceBuilderExtensions public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import, bool isReadOnly) { throw null; } - - public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import) { throw null; } } } diff --git a/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs b/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs index aadec1bec18..edd18841978 100644 --- a/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs +++ b/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs @@ -19,8 +19,6 @@ public static ApplicationModel.IResourceBuilder WithAttu(this ApplicationM public static ApplicationModel.IResourceBuilder WithConfigurationBindMount(this ApplicationModel.IResourceBuilder builder, string configurationFilePath) { throw null; } - public static ApplicationModel.IResourceBuilder WithConfigurationFile(this ApplicationModel.IResourceBuilder builder, string configurationFilePath) { throw null; } - public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } diff --git a/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs b/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs index d9ba8cde863..849ec42a5f1 100644 --- a/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs +++ b/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs @@ -24,8 +24,6 @@ public static partial class MongoDBBuilderExtensions public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } - public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - public static ApplicationModel.IResourceBuilder WithMongoExpress(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.MongoDBServerResource { throw null; } } diff --git a/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs b/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs index 78eb1b19de1..91654c0dc5d 100644 --- a/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs +++ b/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs @@ -22,8 +22,6 @@ public static partial class MySqlBuilderExtensions public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } - public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - public static ApplicationModel.IResourceBuilder WithPhpMyAdmin(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.MySqlServerResource { throw null; } } diff --git a/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs b/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs index 62db80ebf80..4f1c3e96376 100644 --- a/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs +++ b/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs @@ -21,8 +21,6 @@ public static partial class OracleDatabaseBuilderExtensions public static ApplicationModel.IResourceBuilder WithDbSetupBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - - public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } } } diff --git a/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs b/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs index 950dd2dc090..f49cf4ef742 100644 --- a/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs +++ b/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs @@ -26,8 +26,6 @@ public static partial class PostgresBuilderExtensions public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } - public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - public static ApplicationModel.IResourceBuilder WithPgAdmin(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.PostgresServerResource { throw null; } From a2f91775563d9777ea1c25c5600c1074d411b486 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 25 Apr 2025 11:21:37 -0700 Subject: [PATCH 06/11] Don't change isReadOnly --- src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs index a42772146aa..ba14cece1c3 100644 --- a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs +++ b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs @@ -16,7 +16,7 @@ public static partial class KeycloakResourceBuilderExtensions public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import, bool isReadOnly) { throw null; } + public static ApplicationModel.IResourceBuilder WithRealmImport(this ApplicationModel.IResourceBuilder builder, string import, bool isReadOnly = false) { throw null; } } } From e94fed1bc88f57393eb79c8bf48a5da18c6ae4d8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 25 Apr 2025 11:44:39 -0700 Subject: [PATCH 07/11] Update another init bind mount usage to WithContainerFiles --- .../AzureEventHubsEmulatorResource.cs | 5 +- .../AzureEventHubsExtensions.cs | 122 +++++++----------- .../ConfigFileAnnotation.cs | 19 +++ 3 files changed, 68 insertions(+), 78 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.EventHubs/ConfigFileAnnotation.cs 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 e2269e162be..0c38dc3feb0 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; @@ -20,8 +21,6 @@ namespace Aspire.Hosting; /// public static class AzureEventHubsExtensions { - private const UnixFileMode FileMode644 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; - /// /// Adds an Azure Event Hubs Namespace resource to the application model. This resource can be used to create Event Hub resources. /// @@ -30,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 . @@ -236,11 +235,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)) @@ -294,77 +292,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(); + 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, - AzureEventHubsEmulatorResource.EmulatorConfigJsonPath, - ContainerMountType.BindMount, - isReadOnly: true)); - } - finally - { - try - { - File.Delete(tempConfigFile); - } - catch - { - } - } - - return Task.CompletedTask; - }); + return Task.FromResult>([ + new ContainerFile + { + Name = AzureEventHubsEmulatorResource.EmulatorConfigJsonFile, + Contents = Encoding.UTF8.GetString(writeStream.ToArray()), + }, + ]); + }); return builder; } @@ -438,14 +414,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); } /// @@ -464,12 +433,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(); // { @@ -499,7 +465,9 @@ private static string WriteEmulatorConfigJson(AzureEventHubsResource emulatorRes writer.WriteEndObject(); // } (/UserConfig) writer.WriteEndObject(); // } (/Root) - return filePath; + writer.Flush(); + + return Encoding.UTF8.GetString(stream.ToArray()); } /// @@ -516,7 +484,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; } +} From 02fd48416b6670e30fe32aad42975e2b3f91f234 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 25 Apr 2025 13:07:02 -0700 Subject: [PATCH 08/11] Add updated test file --- .../AzureEventHubsExtensionsTests.cs | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 991247f4936..c0ba77939d9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -400,9 +400,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*/""" { @@ -429,7 +432,7 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJson() } } } - """, configJsonContent); + """, configFile.Contents); await app.StopAsync(); } @@ -458,19 +461,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*/""" { @@ -494,7 +490,7 @@ public async Task AzureEventHubsEmulatorResourceGeneratesConfigJsonWithCustomiza }, "Custom": 42 } - """, configJsonContent); + """, configFile.Contents); await app.StopAsync(); } @@ -539,13 +535,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(); From 9d6444dc569510a56e3d38e44a51afd55f4f29fb Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 1 May 2025 21:50:43 -0700 Subject: [PATCH 09/11] Remove unused usings --- src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs | 1 - src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index e5a0b270006..e4a26e5675d 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -10,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; diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index c06792e682f..da1a4ce2040 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -10,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; From 48306061c80de9778a529aa47dbe76f7a0f8332b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 9 May 2025 14:08:34 -0700 Subject: [PATCH 10/11] Add support for WithContainerFiles attributes in docker compose publish output --- .../DockerComposePublishingContext.cs | 52 +++++++++++++++++++ .../Resources/ComposeFile.cs | 12 +++++ .../Resources/ComposeNodes/Config.cs | 10 ++-- .../Resources/ComposeNodes/Service.cs | 13 +++++ .../Resources/ServiceNodes/ConfigReference.cs | 2 +- .../UnixFileModeTypeConverter.cs | 39 ++++++++++++++ .../DockerComposePublisherTests.cs | 21 ++++++++ ...eratesValidDockerComposeFile.verified.yaml | 14 +++++ 8 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/Aspire.Hosting.Docker/UnixFileModeTypeConverter.cs diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 1846d8a6e11..b336e7ddee1 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -27,6 +27,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 +88,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 +140,41 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod envFile.Save(envFilePath); } + private static 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; + composeFile.AddConfig(new() + { + Name = name, + File = file.SourcePath, + Content = file.Contents, + }); + + composeService.AddConfig(new() + { + Source = name, + Target = path + "/" + file.Name, + Uid = item.Owner ?? uid, + Gid = item.Group ?? gid, + 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..acf308cee47 100644 --- a/src/Aspire.Hosting.Docker/Resources/ServiceNodes/ConfigReference.cs +++ b/src/Aspire.Hosting.Docker/Resources/ServiceNodes/ConfigReference.cs @@ -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/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 8a069550632..1c1448b6c47 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("/tmp", [ + new ContainerFile + { + Name = "redis.conf", + Contents = "hello world", + }, + new ContainerDirectory + { + Name = "folder", + Entries = [ + new ContainerFile + { + Name = "file.sh", + SourcePath = "./somefile.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..e9d8069ee02 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__tmp_redis.conf" + target: "/tmp/redis.conf" + mode: 0644 + - source: "cache__tmp_folder_file.sh" + target: "/tmp/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__tmp_redis.conf: + content: "hello world" + cache__tmp_folder_file.sh: + file: "./somefile.sh" From 235e7c2e286581d70e71ce93dfea41b845f7f10f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 9 May 2025 17:44:57 -0700 Subject: [PATCH 11/11] Avoid using a path that Verifier will scrub --- .../DockerComposePublisherTests.cs | 2 +- ...ync_GeneratesValidDockerComposeFile.verified.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 1c1448b6c47..b5f2ae849da 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -37,7 +37,7 @@ public async Task PublishAsync_GeneratesValidDockerComposeFile() .WithHttpEndpoint(env: "REDIS_PORT") .WithArgs("-c", "hello $MSG") .WithEnvironment("MSG", "world") - .WithContainerFiles("/tmp", [ + .WithContainerFiles("/usr/local/share", [ new ContainerFile { Name = "redis.conf", 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 e9d8069ee02..79cad8eaaf9 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 @@ -17,11 +17,11 @@ networks: - "aspire" configs: - - source: "cache__tmp_redis.conf" - target: "/tmp/redis.conf" + - source: "cache__usr_local_share_redis.conf" + target: "/usr/local/share/redis.conf" mode: 0644 - - source: "cache__tmp_folder_file.sh" - target: "/tmp/folder/file.sh" + - source: "cache__usr_local_share_folder_file.sh" + target: "/usr/local/share/folder/file.sh" uid: 1000 gid: 1000 mode: 0700 @@ -64,7 +64,7 @@ networks: aspire: driver: "bridge" configs: - cache__tmp_redis.conf: + cache__usr_local_share_redis.conf: content: "hello world" - cache__tmp_folder_file.sh: + cache__usr_local_share_folder_file.sh: file: "./somefile.sh"