diff --git a/playground/AzureServiceBus/ServiceBus.AppHost/Program.cs b/playground/AzureServiceBus/ServiceBus.AppHost/Program.cs index daaad543b9..c629aefd25 100644 --- a/playground/AzureServiceBus/ServiceBus.AppHost/Program.cs +++ b/playground/AzureServiceBus/ServiceBus.AppHost/Program.cs @@ -39,7 +39,7 @@ serviceBus.RunAsEmulator(configure => configure.ConfigureEmulator(document => { document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" }; -})); +}).WithLifetime(ContainerLifetime.Persistent)); builder.AddProject("worker") .WithReference(serviceBus).WaitFor(serviceBus); diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 0ec662d1a5..9a7f4eb236 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -242,6 +242,15 @@ namespace Projects%3B + + + + <_Parameter1>apphostprojectbaseintermediateoutputpath + <_Parameter2>$(BaseIntermediateOutputPath) + + + + manifest $(_AspireIntermediatePath) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 459d204ef7..86913ec26e 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -7,7 +7,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Azure.ServiceBus; -using Aspire.Hosting.Utils; using Azure.Messaging.ServiceBus; using Azure.Provisioning; using Microsoft.Extensions.DependencyInjection; @@ -242,25 +241,20 @@ public static IResourceBuilder AddSubscription(this IRe /// public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { + if (builder.Resource.IsEmulator) + { + throw new InvalidOperationException("The Azure Service Bus resource is already configured to run as an emulator."); + } + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) { return builder; } - // Create a default file mount. This could be replaced by a user-provided file mount. - var configHostFile = Path.Combine(Directory.CreateTempSubdirectory("AspireServiceBusEmulator").FullName, "Config.json"); - - var defaultConfigFileMount = new ContainerMountAnnotation( - configHostFile, - AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, - ContainerMountType.BindMount, - isReadOnly: true); - - builder.WithAnnotation(defaultConfigFileMount); - // Add emulator container - var password = PasswordGenerator.Generate(16, true, true, true, true, 0, 0, 0, 0); + // The password must be at least 8 characters long and contain characters from three of the following four sets: Uppercase letters, Lowercase letters, Base 10 digits, and Symbols + var passwordParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-sql-pwd", minLower: 1, minUpper: 1, minNumeric: 1); builder .WithEndpoint(name: "emulator", targetPort: 5672) @@ -278,7 +272,10 @@ public static IResourceBuilder RunAsEmulator(this IReso .WithImageRegistry(ServiceBusEmulatorContainerImageTags.AzureSqlEdgeRegistry) .WithEndpoint(targetPort: 1433, name: "tcp") .WithEnvironment("ACCEPT_EULA", "Y") - .WithEnvironment("MSSQL_SA_PASSWORD", password) + .WithEnvironment(context => + { + context.EnvironmentVariables["MSSQL_SA_PASSWORD"] = passwordParameter; + }) .WithParentRelationship(builder.Resource); builder.WithAnnotation(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) => @@ -287,152 +284,118 @@ public static IResourceBuilder RunAsEmulator(this IReso context.EnvironmentVariables.Add("ACCEPT_EULA", "Y"); context.EnvironmentVariables.Add("SQL_SERVER", $"{sqlEndpoint.Resource.Name}:{sqlEndpoint.TargetPort}"); - context.EnvironmentVariables.Add("MSSQL_SA_PASSWORD", password); + context.EnvironmentVariables.Add("MSSQL_SA_PASSWORD", passwordParameter); })); - ServiceBusClient? serviceBusClient = null; - string? queueOrTopicName = null; + var lifetime = ContainerLifetime.Session; - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => + if (configureContainer != null) { - var serviceBusEmulatorResources = builder.ApplicationBuilder.Resources.OfType().Where(x => x is { } serviceBusResource && serviceBusResource.IsEmulator); - - if (!serviceBusEmulatorResources.Any()) - { - // No-op if there is no Azure Service Bus emulator resource. - return; - } - - var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); + var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate); + configureContainer(surrogateBuilder); - if (connectionString == null) + if (surrogate.TryGetLastAnnotation(out var lifetimeAnnotation)) { - throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); + lifetime = lifetimeAnnotation.Lifetime; } + } - // Retrieve a queue/topic name to configure the health check - - var noRetryOptions = new ServiceBusClientOptions { RetryOptions = new ServiceBusRetryOptions { MaxRetries = 0 } }; - serviceBusClient = new ServiceBusClient(connectionString, noRetryOptions); + sqlEdgeResource = sqlEdgeResource.WithLifetime(lifetime); - queueOrTopicName = - serviceBusEmulatorResources.SelectMany(x => x.Queues).Select(x => x.Name).FirstOrDefault() - ?? serviceBusEmulatorResources.SelectMany(x => x.Topics).Select(x => x.Name).FirstOrDefault(); + // 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 - foreach (var emulatorResource in serviceBusEmulatorResources) - { - var configFileMount = emulatorResource.Annotations.OfType().Single(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath); + var hasCustomConfigJson = builder.Resource.Annotations.OfType().Any(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath); - // If there is a custom mount for EmulatorConfigJsonPath we don't need to create the Config.json file. - if (configFileMount != defaultConfigFileMount) - { - continue; - } + if (hasCustomConfigJson) + { + return Task.CompletedTask; + } - var fileStreamOptions = new FileStreamOptions() { Mode = FileMode.Create, Access = FileAccess.Write }; + // Create Config.json file content and its alterations in a temporary file + var tempConfigFile = WriteEmulatorConfigJson(builder.Resource); - if (!OperatingSystem.IsWindows()) - { - fileStreamOptions.UnixCreateMode = - UnixFileMode.UserRead | UnixFileMode.UserWrite - | UnixFileMode.GroupRead | UnixFileMode.GroupWrite - | UnixFileMode.OtherRead | UnixFileMode.OtherWrite; - } + try + { + // Apply ConfigJsonAnnotation modifications + var configJsonAnnotations = builder.Resource.Annotations.OfType(); - using (var stream = new FileStream(configFileMount.Source!, fileStreamOptions)) + if (configJsonAnnotations.Any()) { - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - - writer.WriteStartObject(); // { - writer.WriteStartObject("UserConfig"); // "UserConfig": { - writer.WriteStartArray("Namespaces"); // "Namespaces": [ - writer.WriteStartObject(); // { - writer.WriteString("Name", emulatorResource.Name); - writer.WriteStartArray("Queues"); // "Queues": [ + using var readStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Read); + var jsonObject = JsonNode.Parse(readStream); + readStream.Close(); - foreach (var queue in emulatorResource.Queues) + if (jsonObject == null) { - writer.WriteStartObject(); - queue.WriteJsonObjectProperties(writer); - writer.WriteEndObject(); + throw new InvalidOperationException("The configuration file mount could not be parsed."); } - writer.WriteEndArray(); // ] (/Queues) - - writer.WriteStartArray("Topics"); // "Topics": [ - foreach (var topic in emulatorResource.Topics) + foreach (var annotation in configJsonAnnotations) { - writer.WriteStartObject(); // "{ (Topic)" - topic.WriteJsonObjectProperties(writer); - writer.WriteStartArray("Subscriptions"); // "Subscriptions": [ - foreach (var subscription in topic.Subscriptions) - { - writer.WriteStartObject(); // "{ (Subscription)" - subscription.WriteJsonObjectProperties(writer); + annotation.Configure(jsonObject); + } - writer.WriteStartArray("Rules"); // "Rules": [ - foreach (var rule in subscription.Rules) - { - writer.WriteStartObject(); - rule.WriteJsonObjectProperties(writer); - writer.WriteEndObject(); - } + using var writeStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Write); + using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true }); + jsonObject.WriteTo(writer); + } - writer.WriteEndArray(); // ] (/Rules) + var aspireStore = builder.ApplicationBuilder.CreateStore(); - writer.WriteEndObject(); // } (/Subscription) - } + // Deterministic file path for the configuration file based on its content + var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); - writer.WriteEndArray(); // ] (/Subscriptions) + builder.WithAnnotation(new ContainerMountAnnotation( + configJsonPath, + AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, + ContainerMountType.BindMount, + isReadOnly: true)); + } + finally + { + try + { + File.Delete(tempConfigFile); + } + catch + { + } + } - writer.WriteEndObject(); // } (/Topic) - } - writer.WriteEndArray(); // ] (/Topics) + return Task.CompletedTask; + }); - writer.WriteEndObject(); // } (/Namespace) - writer.WriteEndArray(); // ], (/Namespaces) - writer.WriteStartObject("Logging"); // "Logging": { - writer.WriteString("Type", "File"); // "Type": "File" - writer.WriteEndObject(); // } (/LoggingConfig) + ServiceBusClient? serviceBusClient = null; + string? queueOrTopicName = null; - writer.WriteEndObject(); // } (/UserConfig) - writer.WriteEndObject(); // } (/Root) - } + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => + { + var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); - // Apply ConfigJsonAnnotation modifications - var configJsonAnnotations = emulatorResource.Annotations.OfType(); + if (connectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); + } - foreach (var annotation in configJsonAnnotations) - { - using var readStream = new FileStream(configFileMount.Source!, FileMode.Open, FileAccess.Read); - var jsonObject = JsonNode.Parse(readStream); - readStream.Close(); + // Retrieve a queue/topic name to configure the health check - using var writeStream = new FileStream(configFileMount.Source!, FileMode.Open, FileAccess.Write); - using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true }); + var noRetryOptions = new ServiceBusClientOptions { RetryOptions = new ServiceBusRetryOptions { MaxRetries = 0 } }; + serviceBusClient = new ServiceBusClient(connectionString, noRetryOptions); - if (jsonObject == null) - { - throw new InvalidOperationException("The configuration file mount could not be parsed."); - } - annotation.Configure(jsonObject); - jsonObject.WriteTo(writer); - } - } + queueOrTopicName = + builder.Resource.Queues.Select(x => x.Name).FirstOrDefault() + ?? builder.Resource.Topics.Select(x => x.Name).FirstOrDefault(); }); var healthCheckKey = $"{builder.Resource.Name}_check"; - if (configureContainer != null) - { - var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); - var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate); - configureContainer(surrogateBuilder); - } - // To use the existing ServiceBus health check we would need to know if there is any queue or topic defined. // We can register a health check for a queue and then no-op if there are no queues. Same for topics. // If no queues or no topics are defined then the health check will be successful. @@ -476,6 +439,20 @@ public static IResourceBuilder WithConfiguratio /// The builder for the . /// A callback to update the JSON object representation of the configuration. /// A reference to the . + /// + /// Here is an example of how to configure the emulator to use a different logging mechanism: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddAzureServiceBus("servicebusns") + /// .RunAsEmulator(configure => configure + /// .ConfigureEmulator(document => + /// { + /// document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" }; + /// }); + /// ); + /// + /// public static IResourceBuilder ConfigureEmulator(this IResourceBuilder builder, Action configJson) { ArgumentNullException.ThrowIfNull(builder); @@ -499,4 +476,70 @@ public static IResourceBuilder WithHostPort(thi endpoint.Port = port; }); } + + private static string WriteEmulatorConfigJson(AzureServiceBusResource emulatorResource) + { + var filePath = Path.GetTempFileName(); + + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Write); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); // { + writer.WriteStartObject("UserConfig"); // "UserConfig": { + writer.WriteStartArray("Namespaces"); // "Namespaces": [ + writer.WriteStartObject(); // { + writer.WriteString("Name", emulatorResource.Name); + writer.WriteStartArray("Queues"); // "Queues": [ + + foreach (var queue in emulatorResource.Queues) + { + writer.WriteStartObject(); + queue.WriteJsonObjectProperties(writer); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); // ] (/Queues) + + writer.WriteStartArray("Topics"); // "Topics": [ + foreach (var topic in emulatorResource.Topics) + { + writer.WriteStartObject(); // "{ (Topic)" + topic.WriteJsonObjectProperties(writer); + + writer.WriteStartArray("Subscriptions"); // "Subscriptions": [ + foreach (var subscription in topic.Subscriptions) + { + writer.WriteStartObject(); // "{ (Subscription)" + subscription.WriteJsonObjectProperties(writer); + + writer.WriteStartArray("Rules"); // "Rules": [ + foreach (var rule in subscription.Rules) + { + writer.WriteStartObject(); + rule.WriteJsonObjectProperties(writer); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); // ] (/Rules) + + writer.WriteEndObject(); // } (/Subscription) + } + + writer.WriteEndArray(); // ] (/Subscriptions) + + writer.WriteEndObject(); // } (/Topic) + } + writer.WriteEndArray(); // ] (/Topics) + + writer.WriteEndObject(); // } (/Namespace) + writer.WriteEndArray(); // ], (/Namespaces) + writer.WriteStartObject("Logging"); // "Logging": { + writer.WriteString("Type", "File"); // "Type": "File" + writer.WriteEndObject(); // } (/LoggingConfig) + + writer.WriteEndObject(); // } (/UserConfig) + writer.WriteEndObject(); // } (/Root) + + return filePath; + } } diff --git a/src/Aspire.Hosting/AspireStore.cs b/src/Aspire.Hosting/AspireStore.cs new file mode 100644 index 0000000000..8c682cae11 --- /dev/null +++ b/src/Aspire.Hosting/AspireStore.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; + +namespace Aspire.Hosting; + +internal sealed class AspireStore : IAspireStore +{ + private readonly string _basePath; + + /// + /// Initializes a new instance of the class with the specified base path. + /// + /// The base path for the store. + /// A new instance of . + public AspireStore(string basePath) + { + ArgumentNullException.ThrowIfNull(basePath); + + _basePath = basePath; + EnsureDirectory(); + } + + public string BasePath => _basePath; + + public string GetFileNameWithContent(string filenameTemplate, string sourceFilename) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate); + ArgumentNullException.ThrowIfNullOrWhiteSpace(sourceFilename); + + if (!File.Exists(sourceFilename)) + { + throw new FileNotFoundException("The source file '{0}' does not exist.", sourceFilename); + } + + EnsureDirectory(); + + // Strip any folder information from the filename. + filenameTemplate = Path.GetFileName(filenameTemplate); + + var hashStream = File.OpenRead(sourceFilename); + + // Compute the hash of the content. + var hash = SHA256.HashData(hashStream); + + hashStream.Dispose(); + + var name = Path.GetFileNameWithoutExtension(filenameTemplate); + var ext = Path.GetExtension(filenameTemplate); + var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}"); + + if (!File.Exists(finalFilePath)) + { + File.Copy(sourceFilename, finalFilePath, overwrite: true); + } + + return finalFilePath; + } + + public string GetFileNameWithContent(string filenameTemplate, Stream contentStream) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate); + ArgumentNullException.ThrowIfNull(contentStream); + + // Create a temporary file to write the content to. + var tempFileName = Path.GetTempFileName(); + + // Write the content to the temporary file. + using (var fileStream = File.OpenWrite(tempFileName)) + { + contentStream.CopyTo(fileStream); + } + + var finalFilePath = GetFileNameWithContent(filenameTemplate, tempFileName); + + try + { + File.Delete(tempFileName); + } + catch + { + } + + return finalFilePath; + } + + /// + /// Ensures that the directory for the store exists. + /// + private void EnsureDirectory() + { + if (!string.IsNullOrEmpty(_basePath)) + { + Directory.CreateDirectory(_basePath); + } + } +} diff --git a/src/Aspire.Hosting/AspireStoreExtensions.cs b/src/Aspire.Hosting/AspireStoreExtensions.cs new file mode 100644 index 0000000000..c19700be19 --- /dev/null +++ b/src/Aspire.Hosting/AspireStoreExtensions.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for to create an instance. +/// +public static class AspireStoreExtensions +{ + internal const string AspireStorePathKeyName = "Aspire:Store:Path"; + + /// + /// Creates a new App Host store using the provided . + /// + /// The . + /// The . + public static IAspireStore CreateStore(this IDistributedApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var aspireDir = builder.Configuration[AspireStorePathKeyName]; + + if (string.IsNullOrWhiteSpace(aspireDir)) + { + var assemblyMetadata = builder.AppHostAssembly?.GetCustomAttributes(); + aspireDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath"); + + if (string.IsNullOrWhiteSpace(aspireDir)) + { + throw new InvalidOperationException($"Could not determine an appropriate location for local storage. Set the {AspireStorePathKeyName} setting to a folder where the App Host content should be stored."); + } + } + + return new AspireStore(Path.Combine(aspireDir, ".aspire")); + } + + /// + /// Gets the metadata value for the specified key from the assembly metadata. + /// + /// The assembly metadata. + /// The key to look for. + /// The metadata value if found; otherwise, null. + private static string? GetMetadataValue(IEnumerable? assemblyMetadata, string key) => + assemblyMetadata?.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase))?.Value; + +} diff --git a/src/Aspire.Hosting/IAspireStore.cs b/src/Aspire.Hosting/IAspireStore.cs new file mode 100644 index 0000000000..f4c019256a --- /dev/null +++ b/src/Aspire.Hosting/IAspireStore.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +/// +/// Represents a store for managing files in the Aspire hosting environment that can be reused across runs. +/// +/// +/// The store is created in the ./obj folder of the Application Host. +/// If the ASPIRE_STORE_DIR environment variable is set this will be used instead. +/// +/// The store is specific to a instance such that each application can't +/// conflict with others. A .aspire prefix is also used to ensure that the folder can be delete without impacting +/// unrelated files. +/// +public interface IAspireStore +{ + /// + /// Gets the base path of this store. + /// + string BasePath { get; } + + /// + /// Gets a deterministic file path that is a copy of the content from the provided stream. + /// The resulting file name will depend on the content of the stream. + /// + /// A file name to base the result on. + /// A stream containing the content. + /// A deterministic file path with the same content as the provided stream. + string GetFileNameWithContent(string filenameTemplate, Stream contentStream); + + /// + /// Gets a deterministic file path that is a copy of the . + /// The resulting file name will depend on the content of the file. + /// + /// A file name to base the result on. + /// An existing file. + /// A deterministic file path with the same content as . + /// Thrown when the source file does not exist. + string GetFileNameWithContent(string filenameTemplate, string sourceFilename); +} diff --git a/src/Shared/SecretsStore.cs b/src/Shared/SecretsStore.cs index b230234880..3f10dbddce 100644 --- a/src/Shared/SecretsStore.cs +++ b/src/Shared/SecretsStore.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.InteropServices; using System.Text; using System.Text.Json.Nodes; using Microsoft.Extensions.Configuration; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 4a0a014c9f..7803fd043f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -188,6 +188,7 @@ public async Task VerifyWaitForOnServiceBusEmulatorBlocksDependentResources() { var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); using var builder = TestDistributedApplicationBuilder.Create(output); + var healthCheckTcs = new TaskCompletionSource(); builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => @@ -231,6 +232,7 @@ public async Task VerifyAzureServiceBusEmulatorResource() var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(output); + var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() .WithQueue("queue123"); @@ -267,6 +269,7 @@ public async Task VerifyAzureServiceBusEmulatorResource() public void AddAzureServiceBusWithEmulatorGetsExpectedPort(int? port = null) { using var builder = TestDistributedApplicationBuilder.Create(); + var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder => { builder.WithHostPort(port); @@ -601,10 +604,16 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz using var builder = TestDistributedApplicationBuilder.Create(); var serviceBus = builder.AddAzureServiceBus("servicebusns") - .RunAsEmulator(configure => configure.ConfigureEmulator(document => - { - document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" }; - })); + .RunAsEmulator(configure => configure + .ConfigureEmulator(document => + { + document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" }; + }) + .ConfigureEmulator(document => + { + document["Custom"] = JsonValue.Create(42); + }) + ); using var app = builder.Build(); await app.StartAsync(); @@ -627,7 +636,8 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz "Logging": { "Type": "Console" } - } + }, + "Custom": 42 } """, configJsonContent); @@ -692,4 +702,37 @@ public async Task AzureServiceBusEmulator_WithConfigurationFile() { } } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var lifetime = isPersistent ? ContainerLifetime.Persistent : ContainerLifetime.Session; + + var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder => + { + builder.WithLifetime(lifetime); + }); + + var sql = builder.Resources.FirstOrDefault(x => x.Name == "sb-sqledge"); + + Assert.NotNull(sql); + + serviceBus.Resource.TryGetLastAnnotation(out var sbLifetimeAnnotation); + sql.TryGetLastAnnotation(out var sqlLifetimeAnnotation); + + Assert.Equal(lifetime, sbLifetimeAnnotation?.Lifetime); + Assert.Equal(lifetime, sqlLifetimeAnnotation?.Lifetime); + } + + [Fact] + public void RunAsEmulator_CalledTwice_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(); + + Assert.Throws(() => serviceBus.RunAsEmulator()); + } } diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs new file mode 100644 index 0000000000..29c5411c5a --- /dev/null +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -0,0 +1,115 @@ +// 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.Utils; +using Xunit; + +namespace Aspire.Hosting.Tests; + +public class AspireStoreTests +{ + [Fact] + public void Create_ShouldInitializeStore() + { + var store = CreateStore(); + + Assert.NotNull(store); + Assert.True(Directory.Exists(Path.GetDirectoryName(store.BasePath))); + } + + [Fact] + public void BasePath_ShouldBeAbsolute() + { + var store = CreateStore(); + + var path = store.BasePath; + + Assert.True(Path.IsPathRooted(path)); + } + + [Fact] + public void BasePath_ShouldUseConfiguration() + { + var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); + var store = builder.CreateStore(); + + var path = store.BasePath; + + Assert.DoesNotContain($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", path); + Assert.Contains(Path.GetTempPath(), path); + } + + [Fact] + public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() + { + var store = CreateStore(); + + var path = store.BasePath; + + Assert.Contains(".aspire", path); + } + + [Fact] + public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() + { + var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); + var store = builder.CreateStore(); + + var filename = "testfile2.txt"; + var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); + var filePath = store.GetFileNameWithContent(filename, content); + + Assert.True(File.Exists(filePath)); + Assert.Equal("Test content", File.ReadAllText(filePath)); + } + + [Fact] + public void GetOrCreateFileWithContent_ShouldCreateFile_WithFileContent() + { + var store = CreateStore(); + + var filename = "testfile2.txt"; + var tempFilename = Path.GetTempFileName(); + File.WriteAllText(tempFilename, "Test content"); + var filePath = store.GetFileNameWithContent(filename, tempFilename); + + Assert.True(File.Exists(filePath)); + Assert.Equal("Test content", File.ReadAllText(filePath)); + + try + { + File.Delete(tempFilename); + } + catch + { + } + } + + [Fact] + public void GetOrCreateFileWithContent_ShouldNotRecreateFile() + { + var store = CreateStore(); + + var filename = "testfile3.txt"; + var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); + var filePath = store.GetFileNameWithContent(filename, content); + + File.WriteAllText(filePath, "updated"); + + content.Position = 0; + var filePath2 = store.GetFileNameWithContent(filename, content); + var content2 = File.ReadAllText(filePath2); + + Assert.Equal("updated", content2); + } + + private static IAspireStore CreateStore() + { + var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); + var store = builder.CreateStore(); + return store; + } +} diff --git a/tests/Aspire.Hosting.Tests/Utils/DistributedApplicationTestingBuilderExtensions.cs b/tests/Aspire.Hosting.Tests/Utils/DistributedApplicationTestingBuilderExtensions.cs index 3543144a09..2ab4fa0501 100644 --- a/tests/Aspire.Hosting.Tests/Utils/DistributedApplicationTestingBuilderExtensions.cs +++ b/tests/Aspire.Hosting.Tests/Utils/DistributedApplicationTestingBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Testing; @@ -23,4 +23,9 @@ public static IDistributedApplicationTestingBuilder WithTestAndResourceLogging(t builder.Services.AddLogging(builder => builder.AddFilter("Aspire.Hosting", LogLevel.Trace)); return builder; } + public static IDistributedApplicationTestingBuilder WithTempAspireStore(this IDistributedApplicationTestingBuilder builder) + { + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + return builder; + } } diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs index 205724bbe5..fad36033c2 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs @@ -63,6 +63,8 @@ private static IDistributedApplicationTestingBuilder CreateCore(string[] args, A builder.WithTestAndResourceLogging(testOutputHelper); } + builder.WithTempAspireStore(); + return builder; } }