From 3be1e3d04fe8922bc855b28fc285f5d8d7d45107 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 16 Jan 2025 17:38:54 -0800 Subject: [PATCH 01/19] Fix Azure ServiceBus persistent container support --- .../Aspire.Hosting.Azure.ServiceBus.csproj | 1 + .../AzureServiceBusExtensions.cs | 64 +++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj index 47dce81240..d22c761df3 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj +++ b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index bdb8da57ff..8b0ef940bd 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -1,16 +1,18 @@ // 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; using System.Text.Json; using System.Text.Json.Nodes; 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.Configuration.UserSecrets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.SecretManager.Tools.Internal; using AzureProvisioning = Azure.Provisioning.ServiceBus; namespace Aspire.Hosting; @@ -233,9 +235,29 @@ public static IResourceBuilder RunAsEmulator(this IReso return builder; } + var lifetime = ContainerLifetime.Session; + + if (configureContainer != null) + { + var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); + var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate); + configureContainer(surrogateBuilder); + + if (surrogate.TryGetLastAnnotation(out var lifetimeAnnotation)) + { + lifetime = lifetimeAnnotation.Lifetime; + } + } + // 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"); + if (lifetime == ContainerLifetime.Persistent && builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.AppHostAssembly is not null) + { + configHostFile = GetOrSetUserSecret(builder.ApplicationBuilder.AppHostAssembly, "Parameters:ServiceBusEmulatorConfigFile", configHostFile); + } + var defaultConfigFileMount = new ContainerMountAnnotation( configHostFile, AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, @@ -246,7 +268,8 @@ public static IResourceBuilder RunAsEmulator(this IReso // 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}-sqledge-pwd", minLower: 1, minUpper: 1, minNumeric: 1); builder .WithEndpoint(name: "emulator", targetPort: 5672) @@ -264,7 +287,11 @@ 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; + }) + .WithLifetime(lifetime); builder.WithAnnotation(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) => { @@ -272,7 +299,7 @@ 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; @@ -411,13 +438,6 @@ public static IResourceBuilder RunAsEmulator(this IReso 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. @@ -484,4 +504,26 @@ public static IResourceBuilder WithHostPort(thi endpoint.Port = port; }); } + + private static string GetOrSetUserSecret(Assembly assembly, string name, string value) + { + if (assembly.GetCustomAttribute()?.UserSecretsId is { } userSecretsId) + { + // Save the value to the secret store + try + { + var secretsStore = new SecretsStore(userSecretsId); + if(secretsStore.ContainsKey(name)) + { + return secretsStore[name]!; + } + secretsStore.Set(name, value); + secretsStore.Save(); + return value; + } + catch (Exception) { } + } + + return value; + } } From 86513bf24cc9a6bf70ddfbcc06cc43385caa06dd Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 17 Jan 2025 17:25:29 -0800 Subject: [PATCH 02/19] Refactor state persistence --- .../AzureServiceBusExtensions.cs | 38 ++++--------------- .../ParameterResourceBuilderExtensions.cs | 30 +++++++++++++++ src/Aspire.Hosting/PublicAPI.Unshipped.txt | 1 + 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 8b0ef940bd..95da8a2613 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.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.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; @@ -9,10 +8,8 @@ using Aspire.Hosting.Azure.ServiceBus; using Azure.Messaging.ServiceBus; using Azure.Provisioning; -using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.SecretManager.Tools.Internal; using AzureProvisioning = Azure.Provisioning.ServiceBus; namespace Aspire.Hosting; @@ -237,6 +234,9 @@ public static IResourceBuilder RunAsEmulator(this IReso var lifetime = ContainerLifetime.Session; + // 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"); + if (configureContainer != null) { var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); @@ -249,13 +249,11 @@ public static IResourceBuilder RunAsEmulator(this IReso } } - // 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"); - - if (lifetime == ContainerLifetime.Persistent && builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.AppHostAssembly is not null) + // If the container is persistent, reuse the same configHostFile value across restarts. + if (lifetime == ContainerLifetime.Persistent) { - configHostFile = GetOrSetUserSecret(builder.ApplicationBuilder.AppHostAssembly, "Parameters:ServiceBusEmulatorConfigFile", configHostFile); + var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", configHostFile); + configHostFile = configParameter.Value; } var defaultConfigFileMount = new ContainerMountAnnotation( @@ -504,26 +502,4 @@ public static IResourceBuilder WithHostPort(thi endpoint.Port = port; }); } - - private static string GetOrSetUserSecret(Assembly assembly, string name, string value) - { - if (assembly.GetCustomAttribute()?.UserSecretsId is { } userSecretsId) - { - // Save the value to the secret store - try - { - var secretsStore = new SecretsStore(userSecretsId); - if(secretsStore.ContainsKey(name)) - { - return secretsStore[name]!; - } - secretsStore.Set(name, value); - secretsStore.Save(); - return value; - } - catch (Exception) { } - } - - return value; - } } diff --git a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs index 04473465aa..48fc628f51 100644 --- a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs @@ -51,6 +51,36 @@ public static IResourceBuilder AddParameter(this IDistributed return builder.AddParameter(name, () => value, publishValueAsDefault, secret); } + /// + /// Creates a new that has a generated value using the . + /// + /// + /// The value will be saved to the app host project's user secrets store when is true + /// and the lifetime of the resource is . + /// + /// Distributed application builder + /// Name of the parameter + /// + /// The created . + public static ParameterResource AddPersistentParameter(this IDistributedApplicationBuilder builder, string name, string value) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(value); + + var parameterResource = new ParameterResource(name, defaultValue => GetParameterValue(builder.Configuration, name, defaultValue), true) + { + Default = new ConstantParameterDefault(() => value) + }; + + if (builder.ExecutionContext.IsRunMode && builder.AppHostAssembly is not null) + { + parameterResource.Default = new UserSecretsParameterDefault(builder.AppHostAssembly, builder.Environment.ApplicationName, name, parameterResource.Default); + } + + return parameterResource; + } + /// /// Adds a parameter resource to the application with a value coming from a callback function. /// diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 991f39aa17..ace13437c6 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -244,6 +244,7 @@ static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspir static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! value, bool publishValueAsDefault = false, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Func! valueGetter, bool publishValueAsDefault = false, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameterFromConfiguration(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! configurationKey, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ParameterResourceBuilderExtensions.AddPersistentParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! value) -> Aspire.Hosting.ApplicationModel.ParameterResource! static Aspire.Hosting.ProjectResourceBuilderExtensions.PublishAsDockerFile(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action!>? configure = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ProjectResourceBuilderExtensions.WithEndpointsInEnvironment(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Func! filter) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! Aspire.Hosting.DistributedApplicationExecutionContext.DistributedApplicationExecutionContext(Aspire.Hosting.DistributedApplicationExecutionContextOptions! options) -> void From 1e9de41d18a40870bad10ccb072ca0771741c686 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 22 Jan 2025 12:43:31 -0800 Subject: [PATCH 03/19] Fix tests --- .../AzureServiceBusExtensions.cs | 74 ++++++++++--------- .../AzureServiceBusExtensionsTests.cs | 24 ++++++ 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 95da8a2613..83359132dd 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -232,38 +232,6 @@ public static IResourceBuilder RunAsEmulator(this IReso return builder; } - var lifetime = ContainerLifetime.Session; - - // 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"); - - if (configureContainer != null) - { - var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); - var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate); - configureContainer(surrogateBuilder); - - if (surrogate.TryGetLastAnnotation(out var lifetimeAnnotation)) - { - lifetime = lifetimeAnnotation.Lifetime; - } - } - - // If the container is persistent, reuse the same configHostFile value across restarts. - if (lifetime == ContainerLifetime.Persistent) - { - var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", configHostFile); - configHostFile = configParameter.Value; - } - - var defaultConfigFileMount = new ContainerMountAnnotation( - configHostFile, - AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, - ContainerMountType.BindMount, - isReadOnly: true); - - builder.WithAnnotation(defaultConfigFileMount); - // Add emulator container // 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 @@ -288,8 +256,7 @@ public static IResourceBuilder RunAsEmulator(this IReso .WithEnvironment(context => { context.EnvironmentVariables["MSSQL_SA_PASSWORD"] = passwordParameter; - }) - .WithLifetime(lifetime); + }); builder.WithAnnotation(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) => { @@ -303,6 +270,45 @@ public static IResourceBuilder RunAsEmulator(this IReso ServiceBusClient? serviceBusClient = null; string? queueOrTopicName = null; + var lifetime = ContainerLifetime.Session; + + // 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"); + + if (configureContainer != null) + { + var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); + var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate); + configureContainer(surrogateBuilder); + + if (surrogate.TryGetLastAnnotation(out var lifetimeAnnotation)) + { + lifetime = lifetimeAnnotation.Lifetime; + } + } + + // If the container is persistent, reuse the same configHostFile value across restarts. + if (lifetime == ContainerLifetime.Persistent) + { + var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", configHostFile); + configHostFile = configParameter.Value; + } + + sqlEdgeResource = sqlEdgeResource.WithLifetime(lifetime); + + var defaultConfigFileMount = new ContainerMountAnnotation( + configHostFile, + AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, + ContainerMountType.BindMount, + isReadOnly: true); + + var hasCustomConfigJson = builder.Resource.Annotations.OfType().Any(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath); + + if (!hasCustomConfigJson) + { + builder.WithAnnotation(defaultConfigFileMount); + } + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { var serviceBusEmulatorResources = builder.ApplicationBuilder.Resources.OfType().Where(x => x is { } serviceBusResource && serviceBusResource.IsEmulator); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 4a0a014c9f..725e470182 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -692,4 +692,28 @@ 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); + } } From 97ce49cf7bbf657cfed4d5e37bb7cdf4f7a9de57 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 23 Jan 2025 15:33:12 -0800 Subject: [PATCH 04/19] PR feedback --- .../Aspire.Hosting.Azure.ServiceBus.csproj | 1 - .../AzureServiceBusExtensions.cs | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj index d22c761df3..47dce81240 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj +++ b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj @@ -14,7 +14,6 @@ - diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 83359132dd..8631b4fbaa 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -272,8 +272,7 @@ public static IResourceBuilder RunAsEmulator(this IReso var lifetime = ContainerLifetime.Session; - // 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"); + string configHostFile; if (configureContainer != null) { @@ -293,6 +292,11 @@ public static IResourceBuilder RunAsEmulator(this IReso var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", configHostFile); configHostFile = configParameter.Value; } + else + { + // Otherwise, create a default file mount. This could be replaced by a user-provided file mount. + Path.Combine(Directory.CreateTempSubdirectory("AspireServiceBusEmulator").FullName, "Config.json"); + } sqlEdgeResource = sqlEdgeResource.WithLifetime(lifetime); From ac1e1f5a16e18b0032f41d0f7182df09f4c830e0 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 24 Jan 2025 11:08:13 -0800 Subject: [PATCH 05/19] Fix build --- .../AzureServiceBusExtensions.cs | 6 ++++-- .../ParameterResourceBuilderExtensions.cs | 10 +++++----- src/Aspire.Hosting/PublicAPI.Unshipped.txt | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 8631b4fbaa..8759831bfd 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -286,16 +286,18 @@ public static IResourceBuilder RunAsEmulator(this IReso } } + static string createTempConfigFile() => Path.Combine(Directory.CreateTempSubdirectory("AspireServiceBusEmulator").FullName, "Config.json"); + // If the container is persistent, reuse the same configHostFile value across restarts. if (lifetime == ContainerLifetime.Persistent) { - var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", configHostFile); + var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", createTempConfigFile); configHostFile = configParameter.Value; } else { // Otherwise, create a default file mount. This could be replaced by a user-provided file mount. - Path.Combine(Directory.CreateTempSubdirectory("AspireServiceBusEmulator").FullName, "Config.json"); + configHostFile = createTempConfigFile(); } sqlEdgeResource = sqlEdgeResource.WithLifetime(lifetime); diff --git a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs index 48fc628f51..0f3ac76721 100644 --- a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs @@ -52,7 +52,7 @@ public static IResourceBuilder AddParameter(this IDistributed } /// - /// Creates a new that has a generated value using the . + /// Creates a new that has a generated value using the . /// /// /// The value will be saved to the app host project's user secrets store when is true @@ -60,17 +60,17 @@ public static IResourceBuilder AddParameter(this IDistributed /// /// Distributed application builder /// Name of the parameter - /// + /// A callback returning the default value /// The created . - public static ParameterResource AddPersistentParameter(this IDistributedApplicationBuilder builder, string name, string value) + public static ParameterResource AddPersistentParameter(this IDistributedApplicationBuilder builder, string name, Func valueGetter) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(valueGetter); var parameterResource = new ParameterResource(name, defaultValue => GetParameterValue(builder.Configuration, name, defaultValue), true) { - Default = new ConstantParameterDefault(() => value) + Default = new ConstantParameterDefault(valueGetter) }; if (builder.ExecutionContext.IsRunMode && builder.AppHostAssembly is not null) diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index ace13437c6..64d34bcab7 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -244,7 +244,7 @@ static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspir static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! value, bool publishValueAsDefault = false, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Func! valueGetter, bool publishValueAsDefault = false, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameterFromConfiguration(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! configurationKey, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.ParameterResourceBuilderExtensions.AddPersistentParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! value) -> Aspire.Hosting.ApplicationModel.ParameterResource! +static Aspire.Hosting.ParameterResourceBuilderExtensions.AddPersistentParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Func! valueGetter) -> Aspire.Hosting.ApplicationModel.ParameterResource! static Aspire.Hosting.ProjectResourceBuilderExtensions.PublishAsDockerFile(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action!>? configure = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ProjectResourceBuilderExtensions.WithEndpointsInEnvironment(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Func! filter) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! Aspire.Hosting.DistributedApplicationExecutionContext.DistributedApplicationExecutionContext(Aspire.Hosting.DistributedApplicationExecutionContextOptions! options) -> void From 4ffcda76efdfb1e1925f4d5983059fd98d132b2d Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 28 Jan 2025 17:43:57 -0800 Subject: [PATCH 06/19] Test AspireStore --- .../ServiceBus.AppHost/Program.cs | 2 +- .../Aspire.Hosting.Azure.ServiceBus.csproj | 1 + .../AzureServiceBusExtensions.cs | 31 +-- src/Shared/AspireStore.cs | 252 ++++++++++++++++++ 4 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 src/Shared/AspireStore.cs 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.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj index 47dce81240..0a0b983858 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj +++ b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 8759831bfd..427945e7d2 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -6,6 +6,7 @@ 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; @@ -272,7 +273,10 @@ public static IResourceBuilder RunAsEmulator(this IReso var lifetime = ContainerLifetime.Session; - string configHostFile; + var aspireStore = AspireStore.Create(builder.ApplicationBuilder); + + // Deterministic file path for the configuration file + var configHostFile = aspireStore.GetOrCreateFile("Config.json"); if (configureContainer != null) { @@ -286,20 +290,6 @@ public static IResourceBuilder RunAsEmulator(this IReso } } - static string createTempConfigFile() => Path.Combine(Directory.CreateTempSubdirectory("AspireServiceBusEmulator").FullName, "Config.json"); - - // If the container is persistent, reuse the same configHostFile value across restarts. - if (lifetime == ContainerLifetime.Persistent) - { - var configParameter = ParameterResourceBuilderExtensions.AddPersistentParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-configJson", createTempConfigFile); - configHostFile = configParameter.Value; - } - else - { - // Otherwise, create a default file mount. This could be replaced by a user-provided file mount. - configHostFile = createTempConfigFile(); - } - sqlEdgeResource = sqlEdgeResource.WithLifetime(lifetime); var defaultConfigFileMount = new ContainerMountAnnotation( @@ -353,15 +343,8 @@ public static IResourceBuilder RunAsEmulator(this IReso continue; } - var fileStreamOptions = new FileStreamOptions() { Mode = FileMode.Create, Access = FileAccess.Write }; - - if (!OperatingSystem.IsWindows()) - { - fileStreamOptions.UnixCreateMode = - UnixFileMode.UserRead | UnixFileMode.UserWrite - | UnixFileMode.GroupRead | UnixFileMode.GroupWrite - | UnixFileMode.OtherRead | UnixFileMode.OtherWrite; - } + // Truncate the file since we are going to write to it. + var fileStreamOptions = new FileStreamOptions() { Mode = FileMode.Truncate, Access = FileAccess.Write }; using (var stream = new FileStream(configFileMount.Source!, fileStreamOptions)) { diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs new file mode 100644 index 0000000000..bda60fe58e --- /dev/null +++ b/src/Shared/AspireStore.cs @@ -0,0 +1,252 @@ +// 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; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; +using IdentityModel; + +namespace Aspire.Hosting.Utils; + +internal sealed class AspireStore +{ + private readonly string _storeFilePath; + private readonly string _storeBasePath; + private const string StoreFileName = "aspire.json"; + + private readonly Dictionary _store; + + private AspireStore(string basePath) + { + ArgumentNullException.ThrowIfNull(basePath); + + _storeBasePath = basePath; + _storeFilePath = Path.Combine(basePath, StoreFileName); + + EnsureStoreDirectory(); + + _store = Load(_storeFilePath); + } + + /// + /// Creates a new instance of using the provided . + /// + /// The . + /// A new instance of . + /// + /// The store is created in the following locations: + /// - On Windows: %APPDATA%\Aspire\{applicationHash}\aspire.json + /// - On Mac/Linux: ~/.aspire/{applicationHash}\aspire.json + /// - If none of the above locations are available, the store is created in the directory specified by the ASPIRE_STORE_FALLBACK_DIR environment variable. + /// - If the ASPIRE_STORE_FALLBACK_DIR environment variable is not set, an is thrown. + /// + /// The directory has the permissions set to 700 on Unix systems. + /// + public static AspireStore Create(IDistributedApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + const string aspireStoreFallbackDir = "ASPIRE_STORE_FALLBACK_DIR"; + + var appData = Environment.GetEnvironmentVariable("APPDATA"); + var root = appData // On Windows it goes to %APPDATA%\Microsoft\UserSecrets\ + ?? Environment.GetEnvironmentVariable("HOME") // On Mac/Linux it goes to ~/.microsoft/usersecrets/ + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ?? Environment.GetEnvironmentVariable(aspireStoreFallbackDir); // this fallback is an escape hatch if everything else fails + + if (string.IsNullOrEmpty(root)) + { + throw new InvalidOperationException($"Could not determine an appropriate location for storing user secrets. Set the {aspireStoreFallbackDir} environment variable to a folder where Aspire content should be stored."); + } + + var appName = Sanitize(builder.Environment.ApplicationName).ToLowerInvariant(); + var appNameHash = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant(); + + var directoryPath = !string.IsNullOrEmpty(appData) + ? Path.Combine(root, "Aspire", $"{appName}.{appNameHash}") + : Path.Combine(root, ".aspire", $"{appName}.{appNameHash}"); + + return new AspireStore(directoryPath); + } + + public string? this[string key] => _store[key]; + + public int Count => _store.Count; + + // For testing. + internal string StoreFilePath => _storeFilePath; + + public bool ContainsKey(string key) => _store.ContainsKey(key); + + public IEnumerable> AsEnumerable() => _store; + + public void Clear() => _store.Clear(); + + public void Set(string key, string value) => _store[key] = value; + + public bool Remove(string key) => _store.Remove(key); + + public void Save() + { + EnsureStoreDirectory(); + + var contents = new JsonObject(); + if (_store is not null) + { + foreach (var secret in _store.AsEnumerable()) + { + contents[secret.Key] = secret.Value; + } + } + + // Create a temp file with the correct Unix file mode before moving it to the expected _filePath. + if (!OperatingSystem.IsWindows()) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, _storeFilePath, overwrite: true); + } + + var json = contents.ToJsonString(new() + { + WriteIndented = true + }); + + File.WriteAllText(_storeFilePath, json, Encoding.UTF8); + } + + public string GetOrCreateFileWithContent(string filename, Stream contentStream) + { + // THIS HASN'T BEEN TESTED YET. FOR DISCUSSIONS ONLY. + + ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); + ArgumentNullException.ThrowIfNull(contentStream); + + EnsureStoreDirectory(); + + // Strip any folder information from the filename. + filename = Path.GetFileName(filename); + + // Delete existing file versions with the same name. + var allFiles = Directory.EnumerateFiles(_storeBasePath, filename + ".*"); + + foreach (var file in allFiles) + { + try + { + File.Delete(file); + } + catch + { + } + } + + // 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); + } + + // Compute the hash of the content. + var hash = SHA256.HashData(File.ReadAllBytes(tempFileName)); + + // Move the temporary file to the final location. + // TODO: Use System.Buffers.Text implementation when targeting .NET 9.0 or greater + var finalFilePath = Path.Combine(_storeBasePath, filename, ".", Base64Url.Encode(hash).ToLowerInvariant()); + File.Move(tempFileName, finalFilePath, overwrite: false); + + // If the file already exists, delete the temporary file. + if (File.Exists(tempFileName)) + { + File.Delete(tempFileName); + } + + return finalFilePath; + } + + /// + /// Creates a file with the provided if it does not exist. + /// + /// + /// + public string GetOrCreateFile(string filename) + { + EnsureStoreDirectory(); + + // Strip any folder information from the filename. + filename = Path.GetFileName(filename); + + var finalFilePath = Path.Combine(_storeBasePath, filename); + + if (!File.Exists(finalFilePath)) + { + var tempFileName = Path.GetTempFileName(); + File.Move(tempFileName, finalFilePath, overwrite: false); + } + + return finalFilePath; + } + + internal static string Sanitize(string name) + { + return string.Create(name.Length, name, static (s, name) => + { + // According to the error message from docker CLI, volume names must be of form "[a-zA-Z0-9][a-zA-Z0-9_.-]" + var nameSpan = name.AsSpan(); + + for (var i = 0; i < nameSpan.Length; i++) + { + var c = nameSpan[i]; + + s[i] = IsValidChar(i, c) ? c : '_'; + } + }); + + static bool IsValidChar(int i, char c) + { + if (i == 0 && !(char.IsAsciiLetter(c) || char.IsNumber(c))) + { + // First char must be a letter or number + return false; + } + else if (!(char.IsAsciiLetter(c) || char.IsNumber(c) || c == '_' || c == '.' || c == '-')) + { + // Subsequent chars must be a letter, number, underscore, period, or hyphen + return false; + } + + return true; + } + } + + private void EnsureStoreDirectory() + { + var directoryName = Path.GetDirectoryName(_storeFilePath); + if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) + { + if (!OperatingSystem.IsWindows()) + { + var tempDir = Directory.CreateTempSubdirectory(); + tempDir.MoveTo(directoryName); + } + else + { + Directory.CreateDirectory(directoryName); + } + } + } + + private static Dictionary Load(string storeFilePath) + { + return new ConfigurationBuilder() + .AddJsonFile(storeFilePath, optional: true) + .Build() + .AsEnumerable() + .Where(i => i.Value != null) + .ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase); + } +} From 650e3dff85d6dfbd6cc2ac170b028d380e9d8213 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 29 Jan 2025 11:37:45 -0800 Subject: [PATCH 07/19] Refactor KeyValueStore --- .../Aspire.Hosting.Azure.ServiceBus.csproj | 1 + src/Shared/AspireStore.cs | 122 ++++-------------- src/Shared/SecretsStore.cs | 62 +++++---- 3 files changed, 59 insertions(+), 126 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj index 0a0b983858..77420b8d37 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj +++ b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs index bda60fe58e..3e2849f277 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Shared/AspireStore.cs @@ -1,32 +1,25 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Configuration; using IdentityModel; +using Microsoft.Extensions.SecretManager.Tools.Internal; namespace Aspire.Hosting.Utils; -internal sealed class AspireStore +internal sealed class AspireStore : KeyValueStore { - private readonly string _storeFilePath; private readonly string _storeBasePath; private const string StoreFileName = "aspire.json"; - - private readonly Dictionary _store; + private static readonly SearchValues s_invalidChars = SearchValues.Create(['/', '\\', '?', '%', '*', ':', '|', '"', '<', '>', '.', ' ']); private AspireStore(string basePath) + : base(Path.Combine(basePath, StoreFileName)) { ArgumentNullException.ThrowIfNull(basePath); _storeBasePath = basePath; - _storeFilePath = Path.Combine(basePath, StoreFileName); - - EnsureStoreDirectory(); - - _store = Load(_storeFilePath); } /// @@ -71,49 +64,21 @@ public static AspireStore Create(IDistributedApplicationBuilder builder) return new AspireStore(directoryPath); } - public string? this[string key] => _store[key]; - - public int Count => _store.Count; - - // For testing. - internal string StoreFilePath => _storeFilePath; - - public bool ContainsKey(string key) => _store.ContainsKey(key); - - public IEnumerable> AsEnumerable() => _store; - - public void Clear() => _store.Clear(); - - public void Set(string key, string value) => _store[key] = value; - - public bool Remove(string key) => _store.Remove(key); - - public void Save() + protected override void EnsureDirectory() { - EnsureStoreDirectory(); - - var contents = new JsonObject(); - if (_store is not null) + var directoryName = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) { - foreach (var secret in _store.AsEnumerable()) + if (!OperatingSystem.IsWindows()) { - contents[secret.Key] = secret.Value; + var tempDir = Directory.CreateTempSubdirectory(); + tempDir.MoveTo(directoryName); + } + else + { + Directory.CreateDirectory(directoryName); } } - - // Create a temp file with the correct Unix file mode before moving it to the expected _filePath. - if (!OperatingSystem.IsWindows()) - { - var tempFilename = Path.GetTempFileName(); - File.Move(tempFilename, _storeFilePath, overwrite: true); - } - - var json = contents.ToJsonString(new() - { - WriteIndented = true - }); - - File.WriteAllText(_storeFilePath, json, Encoding.UTF8); } public string GetOrCreateFileWithContent(string filename, Stream contentStream) @@ -123,7 +88,7 @@ public string GetOrCreateFileWithContent(string filename, Stream contentStream) ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); ArgumentNullException.ThrowIfNull(contentStream); - EnsureStoreDirectory(); + EnsureDirectory(); // Strip any folder information from the filename. filename = Path.GetFileName(filename); @@ -175,7 +140,7 @@ public string GetOrCreateFileWithContent(string filename, Stream contentStream) /// public string GetOrCreateFile(string filename) { - EnsureStoreDirectory(); + EnsureDirectory(); // Strip any folder information from the filename. filename = Path.GetFileName(filename); @@ -191,62 +156,21 @@ public string GetOrCreateFile(string filename) return finalFilePath; } - internal static string Sanitize(string name) + /// + /// Removes any unwanted characters from the . + /// + internal static string Sanitize(string filename) { - return string.Create(name.Length, name, static (s, name) => + return string.Create(filename.Length, filename, static (s, name) => { - // According to the error message from docker CLI, volume names must be of form "[a-zA-Z0-9][a-zA-Z0-9_.-]" var nameSpan = name.AsSpan(); for (var i = 0; i < nameSpan.Length; i++) { var c = nameSpan[i]; - s[i] = IsValidChar(i, c) ? c : '_'; + s[i] = s_invalidChars.Contains(c) ? '_' : c; } }); - - static bool IsValidChar(int i, char c) - { - if (i == 0 && !(char.IsAsciiLetter(c) || char.IsNumber(c))) - { - // First char must be a letter or number - return false; - } - else if (!(char.IsAsciiLetter(c) || char.IsNumber(c) || c == '_' || c == '.' || c == '-')) - { - // Subsequent chars must be a letter, number, underscore, period, or hyphen - return false; - } - - return true; - } - } - - private void EnsureStoreDirectory() - { - var directoryName = Path.GetDirectoryName(_storeFilePath); - if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) - { - if (!OperatingSystem.IsWindows()) - { - var tempDir = Directory.CreateTempSubdirectory(); - tempDir.MoveTo(directoryName); - } - else - { - Directory.CreateDirectory(directoryName); - } - } - } - - private static Dictionary Load(string storeFilePath) - { - return new ConfigurationBuilder() - .AddJsonFile(storeFilePath, optional: true) - .Build() - .AsEnumerable() - .Where(i => i.Value != null) - .ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Shared/SecretsStore.cs b/src/Shared/SecretsStore.cs index b230234880..5724b938d8 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; @@ -12,49 +11,49 @@ namespace Microsoft.Extensions.SecretManager.Tools.Internal; /// /// Adapted from dotnet user-secrets at https://github.com/dotnet/aspnetcore/blob/482730a4c773ee4b3ae9525186d10999c89b556d/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs /// -internal sealed class SecretsStore +internal abstract class KeyValueStore { - private readonly string _secretsFilePath; - private readonly Dictionary _secrets; + private readonly string _filePath; + private readonly Dictionary _store; - public SecretsStore(string userSecretsId) + protected KeyValueStore(string filePath) { - ArgumentNullException.ThrowIfNull(userSecretsId); + ArgumentNullException.ThrowIfNull(filePath); - _secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(userSecretsId); + _filePath = filePath; - EnsureUserSecretsDirectory(); + EnsureDirectory(); - _secrets = Load(_secretsFilePath); + _store = Load(_filePath); } - public string? this[string key] => _secrets[key]; + public string? this[string key] => _store[key]; - public int Count => _secrets.Count; + public int Count => _store.Count; // For testing. - internal string SecretsFilePath => _secretsFilePath; + internal string FilePath => _filePath; - public bool ContainsKey(string key) => _secrets.ContainsKey(key); + public bool ContainsKey(string key) => _store.ContainsKey(key); - public IEnumerable> AsEnumerable() => _secrets; + public IEnumerable> AsEnumerable() => _store; - public void Clear() => _secrets.Clear(); + public void Clear() => _store.Clear(); - public void Set(string key, string value) => _secrets[key] = value; + public void Set(string key, string value) => _store[key] = value; - public bool Remove(string key) => _secrets.Remove(key); + public bool Remove(string key) => _store.Remove(key); public void Save() { - EnsureUserSecretsDirectory(); + EnsureDirectory(); var contents = new JsonObject(); - if (_secrets is not null) + if (_store is not null) { - foreach (var secret in _secrets.AsEnumerable()) + foreach (var item in _store.AsEnumerable()) { - contents[secret.Key] = secret.Value; + contents[item.Key] = item.Value; } } @@ -62,7 +61,7 @@ public void Save() if (!OperatingSystem.IsWindows()) { var tempFilename = Path.GetTempFileName(); - File.Move(tempFilename, _secretsFilePath, overwrite: true); + File.Move(tempFilename, _filePath, overwrite: true); } var json = contents.ToJsonString(new() @@ -70,25 +69,34 @@ public void Save() WriteIndented = true }); - File.WriteAllText(_secretsFilePath, json, Encoding.UTF8); + File.WriteAllText(_filePath, json, Encoding.UTF8); } - private void EnsureUserSecretsDirectory() + protected virtual void EnsureDirectory() { - var directoryName = Path.GetDirectoryName(_secretsFilePath); + var directoryName = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) { Directory.CreateDirectory(directoryName); } } - private static Dictionary Load(string secretsFilePath) + private static Dictionary Load(string filePath) { return new ConfigurationBuilder() - .AddJsonFile(secretsFilePath, optional: true) + .AddJsonFile(filePath, optional: true) .Build() .AsEnumerable() .Where(i => i.Value != null) .ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase); } } + +internal sealed class SecretsStore : KeyValueStore +{ + public SecretsStore(string userSecretsId) + : base(PathHelper.GetSecretsPathFromSecretsId(userSecretsId)) + { + ArgumentNullException.ThrowIfNull(userSecretsId); + } +} From d92934687f507417d9285ae7bb1b5f1f68ce16f8 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 29 Jan 2025 12:42:54 -0800 Subject: [PATCH 08/19] Add tests --- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + src/Shared/AspireStore.cs | 32 +++++--- .../Aspire.Hosting.Tests/AspireStoreTests.cs | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/AspireStoreTests.cs diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index e687bfd3bf..441397f1d1 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs index 3e2849f277..ab8361159e 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Shared/AspireStore.cs @@ -12,7 +12,7 @@ internal sealed class AspireStore : KeyValueStore { private readonly string _storeBasePath; private const string StoreFileName = "aspire.json"; - private static readonly SearchValues s_invalidChars = SearchValues.Create(['/', '\\', '?', '%', '*', ':', '|', '"', '<', '>', '.', ' ']); + private static readonly SearchValues s_invalidFileNameChars = SearchValues.Create(Path.GetInvalidFileNameChars()); private AspireStore(string basePath) : base(Path.Combine(basePath, StoreFileName)) @@ -121,11 +121,15 @@ public string GetOrCreateFileWithContent(string filename, Stream contentStream) // Move the temporary file to the final location. // TODO: Use System.Buffers.Text implementation when targeting .NET 9.0 or greater - var finalFilePath = Path.Combine(_storeBasePath, filename, ".", Base64Url.Encode(hash).ToLowerInvariant()); - File.Move(tempFileName, finalFilePath, overwrite: false); + var name = Path.GetFileNameWithoutExtension(filename); + var ext = Path.GetExtension(filename); + var finalFilePath = Path.Combine(_storeBasePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}".ToLowerInvariant()); - // If the file already exists, delete the temporary file. - if (File.Exists(tempFileName)) + if (!File.Exists(finalFilePath)) + { + File.Move(tempFileName, finalFilePath); + } + else { File.Delete(tempFileName); } @@ -156,6 +160,14 @@ public string GetOrCreateFile(string filename) return finalFilePath; } + public void Delete() + { + if (Directory.Exists(_storeBasePath)) + { + Directory.Delete(_storeBasePath, recursive: true); + } + } + /// /// Removes any unwanted characters from the . /// @@ -163,13 +175,11 @@ internal static string Sanitize(string filename) { return string.Create(filename.Length, filename, static (s, name) => { - var nameSpan = name.AsSpan(); - - for (var i = 0; i < nameSpan.Length; i++) + name.CopyTo(s); + var i = -1; + while ((i = s.IndexOfAny(s_invalidFileNameChars)) != -1) { - var c = nameSpan[i]; - - s[i] = s_invalidChars.Contains(c) ? '_' : c; + s[i] = '_'; } }); } diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs new file mode 100644 index 0000000000..39968389ca --- /dev/null +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -0,0 +1,76 @@ +// 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 builder = TestDistributedApplicationBuilder.Create(); + + var store = AspireStore.Create(builder); + + Assert.NotNull(store); + Assert.Equal(0, store.Count); + Assert.True(Directory.Exists(Path.GetDirectoryName(store.FilePath))); + } + + [Fact] + public void GetOrCreateFile_ShouldCreateFileIfNotExists() + { + var builder = TestDistributedApplicationBuilder.Create(); + var store = AspireStore.Create(builder); + + var filename = "testfile1.txt"; + var filePath = store.GetOrCreateFile(filename); + + Assert.True(File.Exists(filePath)); + } + + [Fact] + public void GetOrCreateFileWithContent_ShouldCreateFileWithContent() + { + var builder = TestDistributedApplicationBuilder.Create(); + var store = AspireStore.Create(builder); + + var filename = "testfile2.txt"; + var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); + var filePath = store.GetOrCreateFileWithContent(filename, content); + + Assert.True(File.Exists(filePath)); + Assert.Equal("Test content", File.ReadAllText(filePath)); + } + + [Fact] + public void GetOrCreateFileWithContent_ShouldNotRecreateFile() + { + var builder = TestDistributedApplicationBuilder.Create(); + var store = AspireStore.Create(builder); + + var filename = "testfile3.txt"; + var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); + var filePath = store.GetOrCreateFileWithContent(filename, content); + + File.WriteAllText(filePath, "updated"); + + content.Position = 0; + var filePath2 = store.GetOrCreateFileWithContent(filename, content); + var content2 = File.ReadAllText(filePath2); + + Assert.Equal("updated", content2); + } + + [Fact] + public void Sanitize_ShouldRemoveInvalidCharacters() + { + var invalidFilename = "inva|id:fi*le?name.t Date: Wed, 29 Jan 2025 12:44:16 -0800 Subject: [PATCH 09/19] Update src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs Co-authored-by: Eric Erhardt --- .../AzureServiceBusExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 427945e7d2..04b58ee3f0 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -276,7 +276,7 @@ public static IResourceBuilder RunAsEmulator(this IReso var aspireStore = AspireStore.Create(builder.ApplicationBuilder); // Deterministic file path for the configuration file - var configHostFile = aspireStore.GetOrCreateFile("Config.json"); + var configHostFile = aspireStore.GetOrCreateFile($"{builder.Resource.Name}-Config.json"); if (configureContainer != null) { From d031cd2b868da7444486bd03bc6c6a841d00e597 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 29 Jan 2025 17:54:18 -0800 Subject: [PATCH 10/19] Use /obj folder to store files --- .../build/Aspire.Hosting.AppHost.targets | 9 + .../Aspire.Hosting.Azure.ServiceBus.csproj | 1 - src/Shared/AspireStore.cs | 165 +++++++++++------- src/Shared/SecretsStore.cs | 61 +++---- .../Aspire.Hosting.Tests/AspireStoreTests.cs | 3 +- 5 files changed, 142 insertions(+), 97 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.targets index e6c1dbd206..4fde5eacf0 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.targets @@ -241,6 +241,15 @@ namespace Projects%3B + + + + <_Parameter1>apphostprojectbaseintermediateoutputpath + <_Parameter2>$(BaseIntermediateOutputPath) + + + + manifest $(_AspireIntermediatePath) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj index 77420b8d37..0a0b983858 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj +++ b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj @@ -15,7 +15,6 @@ - diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs index ab8361159e..73093f3266 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Shared/AspireStore.cs @@ -2,26 +2,28 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using System.Reflection; using System.Security.Cryptography; -using IdentityModel; -using Microsoft.Extensions.SecretManager.Tools.Internal; namespace Aspire.Hosting.Utils; -internal sealed class AspireStore : KeyValueStore +internal sealed class AspireStore { - private readonly string _storeBasePath; - private const string StoreFileName = "aspire.json"; + internal const string AspireStoreDir = "ASPIRE_STORE_DIR"; + + private readonly string _basePath; private static readonly SearchValues s_invalidFileNameChars = SearchValues.Create(Path.GetInvalidFileNameChars()); private AspireStore(string basePath) - : base(Path.Combine(basePath, StoreFileName)) { ArgumentNullException.ThrowIfNull(basePath); - _storeBasePath = basePath; + _basePath = basePath; + EnsureDirectory(); } + internal string BasePath => _basePath; + /// /// Creates a new instance of using the provided . /// @@ -40,53 +42,59 @@ public static AspireStore Create(IDistributedApplicationBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - const string aspireStoreFallbackDir = "ASPIRE_STORE_FALLBACK_DIR"; + var assemblyMetadata = builder.AppHostAssembly?.GetCustomAttributes(); + var objDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath"); - var appData = Environment.GetEnvironmentVariable("APPDATA"); - var root = appData // On Windows it goes to %APPDATA%\Microsoft\UserSecrets\ - ?? Environment.GetEnvironmentVariable("HOME") // On Mac/Linux it goes to ~/.microsoft/usersecrets/ + var fallbackDir = Environment.GetEnvironmentVariable(AspireStoreDir); + var root = fallbackDir + ?? objDir + ?? Environment.GetEnvironmentVariable("APPDATA") // On Windows it goes to %APPDATA%\Microsoft\UserSecrets\ + ?? Environment.GetEnvironmentVariable("HOME") // On Mac/Linux it goes to ~/.microsoft/usersecrets/ ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) - ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) - ?? Environment.GetEnvironmentVariable(aspireStoreFallbackDir); // this fallback is an escape hatch if everything else fails + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (string.IsNullOrEmpty(root)) { - throw new InvalidOperationException($"Could not determine an appropriate location for storing user secrets. Set the {aspireStoreFallbackDir} environment variable to a folder where Aspire content should be stored."); + throw new InvalidOperationException($"Could not determine an appropriate location for storing user secrets. Set the {AspireStoreDir} environment variable to a folder where the App Host content should be stored."); } - var appName = Sanitize(builder.Environment.ApplicationName).ToLowerInvariant(); - var appNameHash = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant(); + var directoryPath = Path.Combine(root, ".aspire"); - var directoryPath = !string.IsNullOrEmpty(appData) - ? Path.Combine(root, "Aspire", $"{appName}.{appNameHash}") - : Path.Combine(root, ".aspire", $"{appName}.{appNameHash}"); + // The /obj directory doesn't need to be prefixed with the app host name. + if (root != objDir) + { + directoryPath = Path.Combine(directoryPath, GetAppHostSpecificPrefix(builder)); + } return new AspireStore(directoryPath); } - protected override void EnsureDirectory() + private static string? GetMetadataValue(IEnumerable? assemblyMetadata, string key) => + assemblyMetadata?.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase))?.Value; + + private static string GetAppHostSpecificPrefix(IDistributedApplicationBuilder builder) { - var directoryName = Path.GetDirectoryName(FilePath); - if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) - { - if (!OperatingSystem.IsWindows()) - { - var tempDir = Directory.CreateTempSubdirectory(); - tempDir.MoveTo(directoryName); - } - else - { - Directory.CreateDirectory(directoryName); - } - } + var appName = Sanitize(builder.Environment.ApplicationName).ToLowerInvariant(); + var appNameHash = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant(); + return $"{appName}.{appNameHash}"; } - public string GetOrCreateFileWithContent(string filename, 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 the to base the result on. + /// An existing file. + /// A deterministic file path with the same content as . + public string GetOrCreateFileWithContent(string filename, string sourceFilename) { - // THIS HASN'T BEEN TESTED YET. FOR DISCUSSIONS ONLY. - ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); - ArgumentNullException.ThrowIfNull(contentStream); + ArgumentNullException.ThrowIfNullOrWhiteSpace(sourceFilename); + + if (!File.Exists(sourceFilename)) + { + throw new FileNotFoundException("The source file '{0}' does not exist.", sourceFilename); + } EnsureDirectory(); @@ -94,7 +102,7 @@ public string GetOrCreateFileWithContent(string filename, Stream contentStream) filename = Path.GetFileName(filename); // Delete existing file versions with the same name. - var allFiles = Directory.EnumerateFiles(_storeBasePath, filename + ".*"); + var allFiles = Directory.EnumerateFiles(_basePath, filename + ".*"); foreach (var file in allFiles) { @@ -107,33 +115,43 @@ public string GetOrCreateFileWithContent(string filename, Stream 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 hashStream = File.OpenRead(sourceFilename); // Compute the hash of the content. - var hash = SHA256.HashData(File.ReadAllBytes(tempFileName)); + var hash = SHA256.HashData(hashStream); + + hashStream.Dispose(); - // Move the temporary file to the final location. - // TODO: Use System.Buffers.Text implementation when targeting .NET 9.0 or greater var name = Path.GetFileNameWithoutExtension(filename); var ext = Path.GetExtension(filename); - var finalFilePath = Path.Combine(_storeBasePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}".ToLowerInvariant()); + var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}".ToLowerInvariant()); if (!File.Exists(finalFilePath)) { - File.Move(tempFileName, finalFilePath); + File.Copy(sourceFilename, finalFilePath, overwrite: true); } - else + + return finalFilePath; + } + + public string GetOrCreateFileWithContent(string filename, Stream contentStream) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); + 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)) { - File.Delete(tempFileName); + contentStream.CopyTo(fileStream); } + var finalFilePath = GetOrCreateFileWithContent(filename, tempFileName); + + File.Delete(tempFileName); + return finalFilePath; } @@ -149,7 +167,7 @@ public string GetOrCreateFile(string filename) // Strip any folder information from the filename. filename = Path.GetFileName(filename); - var finalFilePath = Path.Combine(_storeBasePath, filename); + var finalFilePath = Path.Combine(_basePath, filename); if (!File.Exists(finalFilePath)) { @@ -160,11 +178,24 @@ public string GetOrCreateFile(string filename) return finalFilePath; } - public void Delete() + public void DeleteFile(string filename) { - if (Directory.Exists(_storeBasePath)) + // Strip any folder information from the filename. + filename = Path.GetFileName(filename); + + var finalFilePath = Path.Combine(_basePath, filename); + + if (File.Exists(finalFilePath)) { - Directory.Delete(_storeBasePath, recursive: true); + File.Delete(finalFilePath); + } + } + + public void DeleteStore() + { + if (Directory.Exists(_basePath)) + { + Directory.Delete(_basePath, recursive: true); } } @@ -176,11 +207,27 @@ internal static string Sanitize(string filename) return string.Create(filename.Length, filename, static (s, name) => { name.CopyTo(s); - var i = -1; - while ((i = s.IndexOfAny(s_invalidFileNameChars)) != -1) + + while (s.IndexOfAny(s_invalidFileNameChars) is var i and not -1) { s[i] = '_'; } }); } + + private void EnsureDirectory() + { + if (!string.IsNullOrEmpty(_basePath) && !Directory.Exists(_basePath)) + { + if (!OperatingSystem.IsWindows()) + { + var tempDir = Directory.CreateTempSubdirectory(); + tempDir.MoveTo(_basePath); + } + else + { + Directory.CreateDirectory(_basePath); + } + } + } } diff --git a/src/Shared/SecretsStore.cs b/src/Shared/SecretsStore.cs index 5724b938d8..3f10dbddce 100644 --- a/src/Shared/SecretsStore.cs +++ b/src/Shared/SecretsStore.cs @@ -11,49 +11,49 @@ namespace Microsoft.Extensions.SecretManager.Tools.Internal; /// /// Adapted from dotnet user-secrets at https://github.com/dotnet/aspnetcore/blob/482730a4c773ee4b3ae9525186d10999c89b556d/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs /// -internal abstract class KeyValueStore +internal sealed class SecretsStore { - private readonly string _filePath; - private readonly Dictionary _store; + private readonly string _secretsFilePath; + private readonly Dictionary _secrets; - protected KeyValueStore(string filePath) + public SecretsStore(string userSecretsId) { - ArgumentNullException.ThrowIfNull(filePath); + ArgumentNullException.ThrowIfNull(userSecretsId); - _filePath = filePath; + _secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(userSecretsId); - EnsureDirectory(); + EnsureUserSecretsDirectory(); - _store = Load(_filePath); + _secrets = Load(_secretsFilePath); } - public string? this[string key] => _store[key]; + public string? this[string key] => _secrets[key]; - public int Count => _store.Count; + public int Count => _secrets.Count; // For testing. - internal string FilePath => _filePath; + internal string SecretsFilePath => _secretsFilePath; - public bool ContainsKey(string key) => _store.ContainsKey(key); + public bool ContainsKey(string key) => _secrets.ContainsKey(key); - public IEnumerable> AsEnumerable() => _store; + public IEnumerable> AsEnumerable() => _secrets; - public void Clear() => _store.Clear(); + public void Clear() => _secrets.Clear(); - public void Set(string key, string value) => _store[key] = value; + public void Set(string key, string value) => _secrets[key] = value; - public bool Remove(string key) => _store.Remove(key); + public bool Remove(string key) => _secrets.Remove(key); public void Save() { - EnsureDirectory(); + EnsureUserSecretsDirectory(); var contents = new JsonObject(); - if (_store is not null) + if (_secrets is not null) { - foreach (var item in _store.AsEnumerable()) + foreach (var secret in _secrets.AsEnumerable()) { - contents[item.Key] = item.Value; + contents[secret.Key] = secret.Value; } } @@ -61,7 +61,7 @@ public void Save() if (!OperatingSystem.IsWindows()) { var tempFilename = Path.GetTempFileName(); - File.Move(tempFilename, _filePath, overwrite: true); + File.Move(tempFilename, _secretsFilePath, overwrite: true); } var json = contents.ToJsonString(new() @@ -69,34 +69,25 @@ public void Save() WriteIndented = true }); - File.WriteAllText(_filePath, json, Encoding.UTF8); + File.WriteAllText(_secretsFilePath, json, Encoding.UTF8); } - protected virtual void EnsureDirectory() + private void EnsureUserSecretsDirectory() { - var directoryName = Path.GetDirectoryName(_filePath); + var directoryName = Path.GetDirectoryName(_secretsFilePath); if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName)) { Directory.CreateDirectory(directoryName); } } - private static Dictionary Load(string filePath) + private static Dictionary Load(string secretsFilePath) { return new ConfigurationBuilder() - .AddJsonFile(filePath, optional: true) + .AddJsonFile(secretsFilePath, optional: true) .Build() .AsEnumerable() .Where(i => i.Value != null) .ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase); } } - -internal sealed class SecretsStore : KeyValueStore -{ - public SecretsStore(string userSecretsId) - : base(PathHelper.GetSecretsPathFromSecretsId(userSecretsId)) - { - ArgumentNullException.ThrowIfNull(userSecretsId); - } -} diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs index 39968389ca..6976e3b0c4 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -16,8 +16,7 @@ public void Create_ShouldInitializeStore() var store = AspireStore.Create(builder); Assert.NotNull(store); - Assert.Equal(0, store.Count); - Assert.True(Directory.Exists(Path.GetDirectoryName(store.FilePath))); + Assert.True(Directory.Exists(Path.GetDirectoryName(store.BasePath))); } [Fact] From 376fcca495501a9f222e76a6879af63bf397cafc Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 30 Jan 2025 17:21:03 -0800 Subject: [PATCH 11/19] Create ResourcesPreparingEvent --- .../AzureServiceBusExtensions.cs | 230 +++++++++--------- .../BeforeResourcesPreparedEvent.cs | 13 + src/Aspire.Hosting/Dcp/DcpExecutor.cs | 2 + src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs | 1 + .../Orchestrator/ApplicationOrchestrator.cs | 7 + src/Aspire.Hosting/PublicAPI.Unshipped.txt | 2 + src/Shared/AspireStore.cs | 24 +- .../AzureServiceBusExtensionsTests.cs | 9 + .../Aspire.Hosting.Tests/AspireStoreTests.cs | 8 +- .../Dcp/DcpExecutorTests.cs | 59 +++++ 10 files changed, 223 insertions(+), 132 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 04b58ee3f0..f0a2c9a05e 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -228,6 +228,11 @@ 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; @@ -275,9 +280,6 @@ public static IResourceBuilder RunAsEmulator(this IReso var aspireStore = AspireStore.Create(builder.ApplicationBuilder); - // Deterministic file path for the configuration file - var configHostFile = aspireStore.GetOrCreateFile($"{builder.Resource.Name}-Config.json"); - if (configureContainer != null) { var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); @@ -292,131 +294,35 @@ public static IResourceBuilder RunAsEmulator(this IReso sqlEdgeResource = sqlEdgeResource.WithLifetime(lifetime); - var defaultConfigFileMount = new ContainerMountAnnotation( - configHostFile, - AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, - ContainerMountType.BindMount, - isReadOnly: true); - - var hasCustomConfigJson = builder.Resource.Annotations.OfType().Any(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath); - - if (!hasCustomConfigJson) - { - builder.WithAnnotation(defaultConfigFileMount); - } + // 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(builder.Resource, async (@event, ct) => + builder.ApplicationBuilder.Eventing.Subscribe((@event, ct) => { - 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; - } + // Create JSON configuration file - var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + var hasCustomConfigJson = builder.Resource.Annotations.OfType().Any(v => v.Target == AzureServiceBusEmulatorResource.EmulatorConfigJsonPath); - if (connectionString == null) + if (hasCustomConfigJson) { - throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); + return Task.CompletedTask; } - // Retrieve a queue/topic name to configure the health check - - var noRetryOptions = new ServiceBusClientOptions { RetryOptions = new ServiceBusRetryOptions { MaxRetries = 0 } }; - serviceBusClient = new ServiceBusClient(connectionString, noRetryOptions); - - queueOrTopicName = - serviceBusEmulatorResources.SelectMany(x => x.Queues).Select(x => x.Name).FirstOrDefault() - ?? serviceBusEmulatorResources.SelectMany(x => x.Topics).Select(x => x.Name).FirstOrDefault(); - - // Create JSON configuration file + // Create Config.json file content and its alterations in a temporary file + var tempConfigFile = WriteEmulatorConfigJson(builder.Resource); - foreach (var emulatorResource in serviceBusEmulatorResources) + try { - var configFileMount = emulatorResource.Annotations.OfType().Single(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; - } - - // Truncate the file since we are going to write to it. - var fileStreamOptions = new FileStreamOptions() { Mode = FileMode.Truncate, Access = FileAccess.Write }; - - using (var stream = new FileStream(configFileMount.Source!, fileStreamOptions)) - { - 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) - } - // Apply ConfigJsonAnnotation modifications - var configJsonAnnotations = emulatorResource.Annotations.OfType(); + var configJsonAnnotations = builder.Resource.Annotations.OfType(); foreach (var annotation in configJsonAnnotations) { - using var readStream = new FileStream(configFileMount.Source!, FileMode.Open, FileAccess.Read); + using var readStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Read); var jsonObject = JsonNode.Parse(readStream); readStream.Close(); - using var writeStream = new FileStream(configFileMount.Source!, FileMode.Open, FileAccess.Write); + using var writeStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Write); using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true }); if (jsonObject == null) @@ -426,7 +332,41 @@ public static IResourceBuilder RunAsEmulator(this IReso annotation.Configure(jsonObject); jsonObject.WriteTo(writer); } + + // Deterministic file path for the configuration file based on its content + var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); + + builder.WithAnnotation(new ContainerMountAnnotation( + configJsonPath, + AzureServiceBusEmulatorResource.EmulatorConfigJsonPath, + ContainerMountType.BindMount, + isReadOnly: true)); + } + finally + { + File.Delete(tempConfigFile); + } + + return Task.CompletedTask; + }); + + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => + { + var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + + if (connectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); } + + // Retrieve a queue/topic name to configure the health check + + var noRetryOptions = new ServiceBusClientOptions { RetryOptions = new ServiceBusRetryOptions { MaxRetries = 0 } }; + serviceBusClient = new ServiceBusClient(connectionString, noRetryOptions); + + queueOrTopicName = + builder.Resource.Queues.Select(x => x.Name).FirstOrDefault() + ?? builder.Resource.Topics.Select(x => x.Name).FirstOrDefault(); }); var healthCheckKey = $"{builder.Resource.Name}_check"; @@ -497,4 +437,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/ApplicationModel/BeforeResourcesPreparedEvent.cs b/src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs new file mode 100644 index 0000000000..f74def8437 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs @@ -0,0 +1,13 @@ +// 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.Eventing; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// This event is raised by orchestrators before the resources are prepared. +/// +public class BeforeResourcesPreparedEvent() : IDistributedApplicationEvent +{ +} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index e1c55a5ccd..c4dbc3eec5 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -135,6 +135,8 @@ public async Task RunApplicationAsync(CancellationToken cancellationToken = defa try { + await _executorEvents.PublishAsync(new OnResourcesPreparingContext(cancellationToken)).ConfigureAwait(false); + PrepareServices(); PrepareContainers(); PrepareExecutables(); diff --git a/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs b/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs index 9569cd62dd..2e4f1dac49 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.Dcp; internal record ResourceStatus(string? State, DateTime? StartupTimestamp, DateTime? FinishedTimestamp); internal record OnEndpointsAllocatedContext(CancellationToken CancellationToken); internal record OnResourceStartingContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string? DcpResourceName); +internal record OnResourcesPreparingContext(CancellationToken CancellationToken); internal record OnResourcesPreparedContext(CancellationToken CancellationToken); internal record OnResourceChangedContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string DcpResourceName, ResourceStatus Status, Func UpdateSnapshot); internal record OnResourceFailedToStartContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string? DcpResourceName); diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 2d1b1abe67..1d1d60f259 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -41,6 +41,7 @@ public ApplicationOrchestrator(DistributedApplicationModel model, dcpExecutorEvents.Subscribe(OnEndpointsAllocated); dcpExecutorEvents.Subscribe(OnResourceStarting); + dcpExecutorEvents.Subscribe(OnResourcesPreparing); dcpExecutorEvents.Subscribe(OnResourcesPrepared); dcpExecutorEvents.Subscribe(OnResourceChanged); dcpExecutorEvents.Subscribe(OnResourceFailedToStart); @@ -94,6 +95,12 @@ await _notificationService.PublishUpdateAsync(context.Resource, s => s with await _eventing.PublishAsync(beforeResourceStartedEvent, context.CancellationToken).ConfigureAwait(false); } + private async Task OnResourcesPreparing(OnResourcesPreparingContext context) + { + var beforeResourcePreparedEvent = new BeforeResourcesPreparedEvent(); + await _eventing.PublishAsync(beforeResourcePreparedEvent, context.CancellationToken).ConfigureAwait(false); + } + private async Task OnResourcesPrepared(OnResourcesPreparedContext _) { await PublishResourcesWithInitialStateAsync().ConfigureAwait(false); diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index ad314a23f9..6bdcabcfc1 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Aspire.Hosting.ApplicationModel.BeforeResourcesPreparedEvent +Aspire.Hosting.ApplicationModel.BeforeResourcesPreparedEvent.BeforeResourcesPreparedEvent() -> void Aspire.Hosting.ApplicationModel.ContainerLifetime.Session = 0 -> Aspire.Hosting.ApplicationModel.ContainerLifetime Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Relationships.get -> System.Collections.Immutable.ImmutableArray diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs index 73093f3266..5a5c571d4a 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Shared/AspireStore.cs @@ -86,7 +86,7 @@ private static string GetAppHostSpecificPrefix(IDistributedApplicationBuilder bu /// A file name the to base the result on. /// An existing file. /// A deterministic file path with the same content as . - public string GetOrCreateFileWithContent(string filename, string sourceFilename) + public string GetFileNameWithContent(string filename, string sourceFilename) { ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); ArgumentNullException.ThrowIfNullOrWhiteSpace(sourceFilename); @@ -134,7 +134,7 @@ public string GetOrCreateFileWithContent(string filename, string sourceFilename) return finalFilePath; } - public string GetOrCreateFileWithContent(string filename, Stream contentStream) + public string GetFileNameWithContent(string filename, Stream contentStream) { ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); ArgumentNullException.ThrowIfNull(contentStream); @@ -148,7 +148,7 @@ public string GetOrCreateFileWithContent(string filename, Stream contentStream) contentStream.CopyTo(fileStream); } - var finalFilePath = GetOrCreateFileWithContent(filename, tempFileName); + var finalFilePath = GetFileNameWithContent(filename, tempFileName); File.Delete(tempFileName); @@ -156,26 +156,18 @@ public string GetOrCreateFileWithContent(string filename, Stream contentStream) } /// - /// Creates a file with the provided if it does not exist. + /// Creates a file with the provided in the store. /// - /// - /// - public string GetOrCreateFile(string filename) + /// The file name to use in the store. + /// The absolute file name in the store. + public string GetFileName(string filename) { EnsureDirectory(); // Strip any folder information from the filename. filename = Path.GetFileName(filename); - var finalFilePath = Path.Combine(_basePath, filename); - - if (!File.Exists(finalFilePath)) - { - var tempFileName = Path.GetTempFileName(); - File.Move(tempFileName, finalFilePath, overwrite: false); - } - - return finalFilePath; + return Path.Combine(_basePath, filename); } public void DeleteFile(string filename) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 725e470182..4e012715ed 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -716,4 +716,13 @@ public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent) 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 index 6976e3b0c4..c2d4011a1f 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -26,7 +26,7 @@ public void GetOrCreateFile_ShouldCreateFileIfNotExists() var store = AspireStore.Create(builder); var filename = "testfile1.txt"; - var filePath = store.GetOrCreateFile(filename); + var filePath = store.GetFileName(filename); Assert.True(File.Exists(filePath)); } @@ -39,7 +39,7 @@ public void GetOrCreateFileWithContent_ShouldCreateFileWithContent() var filename = "testfile2.txt"; var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); - var filePath = store.GetOrCreateFileWithContent(filename, content); + var filePath = store.GetFileNameWithContent(filename, content); Assert.True(File.Exists(filePath)); Assert.Equal("Test content", File.ReadAllText(filePath)); @@ -53,12 +53,12 @@ public void GetOrCreateFileWithContent_ShouldNotRecreateFile() var filename = "testfile3.txt"; var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); - var filePath = store.GetOrCreateFileWithContent(filename, content); + var filePath = store.GetFileNameWithContent(filename, content); File.WriteAllText(filePath, "updated"); content.Position = 0; - var filePath2 = store.GetOrCreateFileWithContent(filename, content); + var filePath2 = store.GetFileNameWithContent(filename, content); var content2 = File.ReadAllText(filePath2); Assert.Equal("updated", content2); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 502c059b31..4a2b4534c0 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -1002,6 +1002,65 @@ public async Task AddsDefaultsCommandsToResources() HasKnownCommandAnnotations(project.Resource); } + [Fact] + public async Task ResourcesPreparing_ProjectHasReplicas_EventRaisedOnce() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + + var resource = builder.AddProject("ServiceA") + .WithReplicas(2).Resource; + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" }; + + var startingEvents = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + startingEvents.Add(context); + return Task.CompletedTask; + }); + + var channel = Channel.CreateUnbounded(); + events.Subscribe(async (context) => + { + if (context.Resource == resource) + { + await channel.Writer.WriteAsync(context.DcpResourceName); + } + }); + + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events); + await appExecutor.RunApplicationAsync(); + + var executables = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Equal(2, executables.Count); + + var e = Assert.Single(startingEvents); + + var resourceIds = new HashSet(); + var watchResourceTask = Task.Run(async () => + { + await foreach (var item in channel.Reader.ReadAllAsync()) + { + resourceIds.Add(item); + if (resourceIds.Count == 2) + { + break; + } + } + }); + await watchResourceTask.DefaultTimeout(); + + Assert.Equal(2, resourceIds.Count); + } private static void HasKnownCommandAnnotations(IResource resource) { var commandAnnotations = resource.Annotations.OfType().ToList(); From b8a102506786887f64d793de2b7ac1030190e1d1 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 3 Feb 2025 10:26:49 -0800 Subject: [PATCH 12/19] Remove unused AddPersistentParameter --- .../AzureServiceBusExtensions.cs | 2 +- .../ParameterResourceBuilderExtensions.cs | 30 ------------------- src/Aspire.Hosting/PublicAPI.Unshipped.txt | 1 - 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index c04a312da5..5030585f44 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -241,7 +241,7 @@ public static IResourceBuilder RunAsEmulator(this IReso // Add emulator container // 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}-sqledge-pwd", minLower: 1, minUpper: 1, minNumeric: 1); + var passwordParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder.ApplicationBuilder, $"{builder.Resource.Name}-sql-pwd", minLower: 1, minUpper: 1, minNumeric: 1); builder .WithEndpoint(name: "emulator", targetPort: 5672) diff --git a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs index 7f3dcb92dc..80a37e9776 100644 --- a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs @@ -56,36 +56,6 @@ public static IResourceBuilder AddParameter(this IDistributed return builder.AddParameter(name, () => value, publishValueAsDefault, secret); } - /// - /// Creates a new that has a generated value using the . - /// - /// - /// The value will be saved to the app host project's user secrets store when is true - /// and the lifetime of the resource is . - /// - /// Distributed application builder - /// Name of the parameter - /// A callback returning the default value - /// The created . - public static ParameterResource AddPersistentParameter(this IDistributedApplicationBuilder builder, string name, Func valueGetter) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(valueGetter); - - var parameterResource = new ParameterResource(name, defaultValue => GetParameterValue(builder.Configuration, name, defaultValue), true) - { - Default = new ConstantParameterDefault(valueGetter) - }; - - if (builder.ExecutionContext.IsRunMode && builder.AppHostAssembly is not null) - { - parameterResource.Default = new UserSecretsParameterDefault(builder.AppHostAssembly, builder.Environment.ApplicationName, name, parameterResource.Default); - } - - return parameterResource; - } - /// /// Adds a parameter resource to the application with a value coming from a callback function. /// diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index bc6cd83a55..4294e310b4 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -272,7 +272,6 @@ static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspir static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! value, bool publishValueAsDefault = false, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Func! valueGetter, bool publishValueAsDefault = false, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameterFromConfiguration(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! configurationKey, bool secret = false) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! -static Aspire.Hosting.ParameterResourceBuilderExtensions.AddPersistentParameter(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Func! valueGetter) -> Aspire.Hosting.ApplicationModel.ParameterResource! static Aspire.Hosting.ProjectResourceBuilderExtensions.PublishAsDockerFile(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action!>? configure = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ProjectResourceBuilderExtensions.WithEndpointsInEnvironment(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Func! filter) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! Aspire.Hosting.DistributedApplicationExecutionContext.DistributedApplicationExecutionContext(Aspire.Hosting.DistributedApplicationExecutionContextOptions! options) -> void From 727f0f065b71b5359ce49a5320f065103581358f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 3 Feb 2025 12:25:32 -0800 Subject: [PATCH 13/19] Only fallback folder on ENV --- src/Shared/AspireStore.cs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs index 5a5c571d4a..9ad477ffc7 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Shared/AspireStore.cs @@ -46,12 +46,7 @@ public static AspireStore Create(IDistributedApplicationBuilder builder) var objDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath"); var fallbackDir = Environment.GetEnvironmentVariable(AspireStoreDir); - var root = fallbackDir - ?? objDir - ?? Environment.GetEnvironmentVariable("APPDATA") // On Windows it goes to %APPDATA%\Microsoft\UserSecrets\ - ?? Environment.GetEnvironmentVariable("HOME") // On Mac/Linux it goes to ~/.microsoft/usersecrets/ - ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) - ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var root = fallbackDir ?? objDir; if (string.IsNullOrEmpty(root)) { @@ -211,15 +206,7 @@ private void EnsureDirectory() { if (!string.IsNullOrEmpty(_basePath) && !Directory.Exists(_basePath)) { - if (!OperatingSystem.IsWindows()) - { - var tempDir = Directory.CreateTempSubdirectory(); - tempDir.MoveTo(_basePath); - } - else - { - Directory.CreateDirectory(_basePath); - } + Directory.CreateDirectory(_basePath); } } } From 417c0ff7e988cf3827eeda157929ec39287737a6 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 3 Feb 2025 12:30:56 -0800 Subject: [PATCH 14/19] Fix method documentation --- src/Shared/AspireStore.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Shared/AspireStore.cs b/src/Shared/AspireStore.cs index 9ad477ffc7..d1cc5c373e 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Shared/AspireStore.cs @@ -30,13 +30,8 @@ private AspireStore(string basePath) /// The . /// A new instance of . /// - /// The store is created in the following locations: - /// - On Windows: %APPDATA%\Aspire\{applicationHash}\aspire.json - /// - On Mac/Linux: ~/.aspire/{applicationHash}\aspire.json - /// - If none of the above locations are available, the store is created in the directory specified by the ASPIRE_STORE_FALLBACK_DIR environment variable. - /// - If the ASPIRE_STORE_FALLBACK_DIR environment variable is not set, an is thrown. - /// - /// The directory has the permissions set to 700 on Unix systems. + /// 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. /// public static AspireStore Create(IDistributedApplicationBuilder builder) { From 663ee7a5c44820b8d1473ae7c7170d0edb5b8790 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 3 Feb 2025 16:21:36 -0800 Subject: [PATCH 15/19] Remove newly added event --- .../Aspire.Hosting.Azure.ServiceBus.csproj | 1 - .../AzureServiceBusExtensions.cs | 11 ++- .../BeforeResourcesPreparedEvent.cs | 13 --- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 - src/{Shared => Aspire.Hosting}/AspireStore.cs | 76 +++++++++++------- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 2 - src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs | 1 - .../Orchestrator/ApplicationOrchestrator.cs | 7 -- src/Aspire.Hosting/PublicAPI.Unshipped.txt | 7 +- .../Aspire.Hosting.Tests/AspireStoreTests.cs | 79 ++++++++++++++++++- .../Dcp/DcpExecutorTests.cs | 59 -------------- 11 files changed, 139 insertions(+), 118 deletions(-) delete mode 100644 src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs rename src/{Shared => Aspire.Hosting}/AspireStore.cs (73%) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj index 0a0b983858..47dce81240 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj +++ b/src/Aspire.Hosting.Azure.ServiceBus/Aspire.Hosting.Azure.ServiceBus.csproj @@ -14,7 +14,6 @@ - diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 5030585f44..82ddb2aac4 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -6,7 +6,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; @@ -298,7 +297,7 @@ 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) => + builder.ApplicationBuilder.Eventing.Subscribe((@event, ct) => { // Create JSON configuration file @@ -345,7 +344,13 @@ public static IResourceBuilder RunAsEmulator(this IReso } finally { - File.Delete(tempConfigFile); + try + { + File.Delete(tempConfigFile); + } + catch + { + } } return Task.CompletedTask; diff --git a/src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs b/src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs deleted file mode 100644 index f74def8437..0000000000 --- a/src/Aspire.Hosting/ApplicationModel/BeforeResourcesPreparedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -// 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.Eventing; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// This event is raised by orchestrators before the resources are prepared. -/// -public class BeforeResourcesPreparedEvent() : IDistributedApplicationEvent -{ -} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 441397f1d1..e687bfd3bf 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -34,7 +34,6 @@ - diff --git a/src/Shared/AspireStore.cs b/src/Aspire.Hosting/AspireStore.cs similarity index 73% rename from src/Shared/AspireStore.cs rename to src/Aspire.Hosting/AspireStore.cs index d1cc5c373e..070fd47a45 100644 --- a/src/Shared/AspireStore.cs +++ b/src/Aspire.Hosting/AspireStore.cs @@ -5,15 +5,22 @@ using System.Reflection; using System.Security.Cryptography; -namespace Aspire.Hosting.Utils; +namespace Aspire.Hosting; -internal sealed class AspireStore +/// +/// Represents a store for managing files in the Aspire hosting environment that can be reused across runs. +/// +public class AspireStore { - internal const string AspireStoreDir = "ASPIRE_STORE_DIR"; + internal const string AspireStorePathKeyName = "Aspire:Store:Path"; private readonly string _basePath; private static readonly SearchValues s_invalidFileNameChars = SearchValues.Create(Path.GetInvalidFileNameChars()); + /// + /// Initializes a new instance of the class with the specified base path. + /// + /// The base path for the store. private AspireStore(string basePath) { ArgumentNullException.ThrowIfNull(basePath); @@ -22,6 +29,9 @@ private AspireStore(string basePath) EnsureDirectory(); } + /// + /// Gets the base path of the store. + /// internal string BasePath => _basePath; /// @@ -40,12 +50,13 @@ public static AspireStore Create(IDistributedApplicationBuilder builder) var assemblyMetadata = builder.AppHostAssembly?.GetCustomAttributes(); var objDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath"); - var fallbackDir = Environment.GetEnvironmentVariable(AspireStoreDir); + var fallbackDir = builder.Configuration[AspireStorePathKeyName]; + var root = fallbackDir ?? objDir; if (string.IsNullOrEmpty(root)) { - throw new InvalidOperationException($"Could not determine an appropriate location for storing user secrets. Set the {AspireStoreDir} environment variable to a folder where the App Host content should be stored."); + throw new InvalidOperationException($"Could not determine an appropriate location for storing user secrets. Set the {AspireStorePathKeyName} setting to a folder where the App Host content should be stored."); } var directoryPath = Path.Combine(root, ".aspire"); @@ -59,9 +70,20 @@ public static AspireStore Create(IDistributedApplicationBuilder builder) return new AspireStore(directoryPath); } + /// + /// 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; + /// + /// Gets the application host specific prefix based on the builder's environment. + /// + /// The . + /// The application host specific prefix. private static string GetAppHostSpecificPrefix(IDistributedApplicationBuilder builder) { var appName = Sanitize(builder.Environment.ApplicationName).ToLowerInvariant(); @@ -73,9 +95,10 @@ private static string GetAppHostSpecificPrefix(IDistributedApplicationBuilder bu /// 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 the to base the result on. + /// 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. public string GetFileNameWithContent(string filename, string sourceFilename) { ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); @@ -124,6 +147,13 @@ public string GetFileNameWithContent(string filename, string sourceFilename) return finalFilePath; } + /// + /// 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. public string GetFileNameWithContent(string filename, Stream contentStream) { ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); @@ -140,7 +170,13 @@ public string GetFileNameWithContent(string filename, Stream contentStream) var finalFilePath = GetFileNameWithContent(filename, tempFileName); - File.Delete(tempFileName); + try + { + File.Delete(tempFileName); + } + catch + { + } return finalFilePath; } @@ -160,30 +196,11 @@ public string GetFileName(string filename) return Path.Combine(_basePath, filename); } - public void DeleteFile(string filename) - { - // Strip any folder information from the filename. - filename = Path.GetFileName(filename); - - var finalFilePath = Path.Combine(_basePath, filename); - - if (File.Exists(finalFilePath)) - { - File.Delete(finalFilePath); - } - } - - public void DeleteStore() - { - if (Directory.Exists(_basePath)) - { - Directory.Delete(_basePath, recursive: true); - } - } - /// /// Removes any unwanted characters from the . /// + /// The filename to sanitize. + /// The sanitized filename. internal static string Sanitize(string filename) { return string.Create(filename.Length, filename, static (s, name) => @@ -197,6 +214,9 @@ internal static string Sanitize(string filename) }); } + /// + /// Ensures that the directory for the store exists. + /// private void EnsureDirectory() { if (!string.IsNullOrEmpty(_basePath) && !Directory.Exists(_basePath)) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index afad7a92bb..660edbcfa5 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -114,8 +114,6 @@ public async Task RunApplicationAsync(CancellationToken cancellationToken = defa try { - await _executorEvents.PublishAsync(new OnResourcesPreparingContext(cancellationToken)).ConfigureAwait(false); - PrepareServices(); PrepareContainers(); PrepareExecutables(); diff --git a/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs b/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs index 2e4f1dac49..9569cd62dd 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutorEvents.cs @@ -9,7 +9,6 @@ namespace Aspire.Hosting.Dcp; internal record ResourceStatus(string? State, DateTime? StartupTimestamp, DateTime? FinishedTimestamp); internal record OnEndpointsAllocatedContext(CancellationToken CancellationToken); internal record OnResourceStartingContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string? DcpResourceName); -internal record OnResourcesPreparingContext(CancellationToken CancellationToken); internal record OnResourcesPreparedContext(CancellationToken CancellationToken); internal record OnResourceChangedContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string DcpResourceName, ResourceStatus Status, Func UpdateSnapshot); internal record OnResourceFailedToStartContext(CancellationToken CancellationToken, string ResourceType, IResource Resource, string? DcpResourceName); diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index bc4c536b93..dd2f071bcd 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -41,7 +41,6 @@ public ApplicationOrchestrator(DistributedApplicationModel model, dcpExecutorEvents.Subscribe(OnEndpointsAllocated); dcpExecutorEvents.Subscribe(OnResourceStarting); - dcpExecutorEvents.Subscribe(OnResourcesPreparing); dcpExecutorEvents.Subscribe(OnResourcesPrepared); dcpExecutorEvents.Subscribe(OnResourceChanged); dcpExecutorEvents.Subscribe(OnResourceFailedToStart); @@ -134,12 +133,6 @@ await _notificationService.PublishUpdateAsync(context.Resource, s => s with await _eventing.PublishAsync(beforeResourceStartedEvent, context.CancellationToken).ConfigureAwait(false); } - private async Task OnResourcesPreparing(OnResourcesPreparingContext context) - { - var beforeResourcePreparedEvent = new BeforeResourcesPreparedEvent(); - await _eventing.PublishAsync(beforeResourcePreparedEvent, context.CancellationToken).ConfigureAwait(false); - } - private async Task OnResourcesPrepared(OnResourcesPreparedContext _) { await PublishResourcesWithInitialStateAsync().ConfigureAwait(false); diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 4294e310b4..061fbd901e 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -1,6 +1,4 @@ #nullable enable -Aspire.Hosting.ApplicationModel.BeforeResourcesPreparedEvent -Aspire.Hosting.ApplicationModel.BeforeResourcesPreparedEvent.BeforeResourcesPreparedEvent() -> void Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext.ExecutionContext.get -> Aspire.Hosting.DistributedApplicationExecutionContext! Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext.ExecutionContext.init -> void Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext.Logger.get -> Microsoft.Extensions.Logging.ILogger! @@ -193,6 +191,10 @@ Aspire.Hosting.ApplicationModel.WaitAnnotation.WaitType.get -> Aspire.Hosting.Ap Aspire.Hosting.ApplicationModel.WaitType Aspire.Hosting.ApplicationModel.WaitType.WaitForCompletion = 1 -> Aspire.Hosting.ApplicationModel.WaitType Aspire.Hosting.ApplicationModel.WaitType.WaitUntilHealthy = 0 -> Aspire.Hosting.ApplicationModel.WaitType +Aspire.Hosting.AspireStore +Aspire.Hosting.AspireStore.GetFileName(string! filename) -> string! +Aspire.Hosting.AspireStore.GetFileNameWithContent(string! filename, string! sourceFilename) -> string! +Aspire.Hosting.AspireStore.GetFileNameWithContent(string! filename, System.IO.Stream! contentStream) -> string! Aspire.Hosting.DistributedApplicationBuilder.AppHostPath.get -> string! Aspire.Hosting.DistributedApplicationBuilder.Eventing.get -> Aspire.Hosting.Eventing.IDistributedApplicationEventing! Aspire.Hosting.DistributedApplicationBuilderExtensions @@ -256,6 +258,7 @@ Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync static Aspire.Hosting.ApplicationModel.ResourceExtensions.HasAnnotationIncludingAncestorsOfType(this Aspire.Hosting.ApplicationModel.IResource! resource) -> bool static Aspire.Hosting.ApplicationModel.ResourceExtensions.HasAnnotationOfType(this Aspire.Hosting.ApplicationModel.IResource! resource) -> bool static Aspire.Hosting.ApplicationModel.ResourceExtensions.TryGetAnnotationsIncludingAncestorsOfType(this Aspire.Hosting.ApplicationModel.IResource! resource, out System.Collections.Generic.IEnumerable? result) -> bool +static Aspire.Hosting.AspireStore.Create(Aspire.Hosting.IDistributedApplicationBuilder! builder) -> Aspire.Hosting.AspireStore! static Aspire.Hosting.ContainerResourceBuilderExtensions.WithBuildArg(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, object? value) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ContainerResourceBuilderExtensions.WithContainerName(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ContainerResourceBuilderExtensions.WithEndpointProxySupport(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, bool proxyEnabled) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs index c2d4011a1f..c02e64c3a0 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -19,6 +19,60 @@ public void Create_ShouldInitializeStore() Assert.True(Directory.Exists(Path.GetDirectoryName(store.BasePath))); } + [Fact] + public void BasePath_ShouldUseObj() + { + var builder = TestDistributedApplicationBuilder.Create(); + + var store = AspireStore.Create(builder); + + var path = store.BasePath; + + Assert.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", path); + } + + [Fact] + public void BasePath_ShouldBeAbsolute() + { + var builder = TestDistributedApplicationBuilder.Create(); + + var store = AspireStore.Create(builder); + + var path = store.BasePath; + + Assert.True(Path.IsPathRooted(path)); + } + + [Fact] + public void BasePath_ShouldUseConfiguration() + { + var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); + + var store = AspireStore.Create(builder); + + var path = store.BasePath; + + Assert.DoesNotContain($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", path); + Assert.Contains(Path.GetTempPath(), path); + } + + [Fact] + public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() + { + var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); + builder.Configuration["AppHost:Sha256"] = "0123456789abcdef"; + + var store = AspireStore.Create(builder); + + var path = store.BasePath; + + Assert.Contains(builder.Environment.ApplicationName.ToLowerInvariant(), path); + Assert.Contains("0123456789", path); + Assert.Contains(".aspire", path); + } + [Fact] public void GetOrCreateFile_ShouldCreateFileIfNotExists() { @@ -32,7 +86,7 @@ public void GetOrCreateFile_ShouldCreateFileIfNotExists() } [Fact] - public void GetOrCreateFileWithContent_ShouldCreateFileWithContent() + public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() { var builder = TestDistributedApplicationBuilder.Create(); var store = AspireStore.Create(builder); @@ -45,6 +99,29 @@ public void GetOrCreateFileWithContent_ShouldCreateFileWithContent() Assert.Equal("Test content", File.ReadAllText(filePath)); } + [Fact] + public void GetOrCreateFileWithContent_ShouldCreateFile_WithFileContent() + { + var builder = TestDistributedApplicationBuilder.Create(); + var store = AspireStore.Create(builder); + + 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() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index fc2445eed7..a2329e8f40 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -1009,65 +1009,6 @@ public async Task AddsDefaultsCommandsToResources() HasKnownCommandAnnotations(project.Resource); } - [Fact] - public async Task ResourcesPreparing_ProjectHasReplicas_EventRaisedOnce() - { - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - - var resource = builder.AddProject("ServiceA") - .WithReplicas(2).Resource; - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" }; - - var startingEvents = new List(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - startingEvents.Add(context); - return Task.CompletedTask; - }); - - var channel = Channel.CreateUnbounded(); - events.Subscribe(async (context) => - { - if (context.Resource == resource) - { - await channel.Writer.WriteAsync(context.DcpResourceName); - } - }); - - var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events); - await appExecutor.RunApplicationAsync(); - - var executables = kubernetesService.CreatedResources.OfType().ToList(); - Assert.Equal(2, executables.Count); - - var e = Assert.Single(startingEvents); - - var resourceIds = new HashSet(); - var watchResourceTask = Task.Run(async () => - { - await foreach (var item in channel.Reader.ReadAllAsync()) - { - resourceIds.Add(item); - if (resourceIds.Count == 2) - { - break; - } - } - }); - await watchResourceTask.DefaultTimeout(); - - Assert.Equal(2, resourceIds.Count); - } private static void HasKnownCommandAnnotations(IResource resource) { var commandAnnotations = resource.Annotations.OfType().ToList(); From ec769ed0900dbf32c55c04f4d3c01c4836f21d5b Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 3 Feb 2025 17:58:23 -0800 Subject: [PATCH 16/19] Fix tests --- src/Aspire.Hosting/AspireStore.cs | 31 ++++++++++------- .../Aspire.Hosting.Tests/AspireStoreTests.cs | 34 ++++++++++--------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/Aspire.Hosting/AspireStore.cs b/src/Aspire.Hosting/AspireStore.cs index 070fd47a45..338e950171 100644 --- a/src/Aspire.Hosting/AspireStore.cs +++ b/src/Aspire.Hosting/AspireStore.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.Buffers; using System.Reflection; using System.Security.Cryptography; @@ -10,12 +9,19 @@ 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 class AspireStore { internal const string AspireStorePathKeyName = "Aspire:Store:Path"; private readonly string _basePath; - private static readonly SearchValues s_invalidFileNameChars = SearchValues.Create(Path.GetInvalidFileNameChars()); /// /// Initializes a new instance of the class with the specified base path. @@ -30,19 +36,15 @@ private AspireStore(string basePath) } /// - /// Gets the base path of the store. + /// Gets the base path of this store. /// - internal string BasePath => _basePath; + public string BasePath => _basePath; /// /// Creates a new instance of using the provided . /// /// The . /// A new instance of . - /// - /// 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. - /// public static AspireStore Create(IDistributedApplicationBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -182,7 +184,7 @@ public string GetFileNameWithContent(string filename, Stream contentStream) } /// - /// Creates a file with the provided in the store. + /// Creates an absolute file name for the provided in the store. /// /// The file name to use in the store. /// The absolute file name in the store. @@ -203,13 +205,18 @@ public string GetFileName(string filename) /// The sanitized filename. internal static string Sanitize(string filename) { + ArgumentException.ThrowIfNullOrEmpty(filename); + return string.Create(filename.Length, filename, static (s, name) => { - name.CopyTo(s); + // First char must be a letter of digit + s[0] = char.IsAsciiLetterOrDigit(name[0]) ? name[0] : '_'; - while (s.IndexOfAny(s_invalidFileNameChars) is var i and not -1) + for (var i = 1; i < name.Length; i++) { - s[i] = '_'; + var c = name[i]; + + s[i] = char.IsAsciiLetterOrDigit(c) || c == '.' ? c : '_'; } }); } diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs index c02e64c3a0..732b3f5d53 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -12,7 +12,7 @@ public class AspireStoreTests public void Create_ShouldInitializeStore() { var builder = TestDistributedApplicationBuilder.Create(); - + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); Assert.NotNull(store); @@ -20,41 +20,40 @@ public void Create_ShouldInitializeStore() } [Fact] - public void BasePath_ShouldUseObj() + public void BasePath_ShouldBeAbsolute() { var builder = TestDistributedApplicationBuilder.Create(); - + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); var path = store.BasePath; - Assert.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", path); + Assert.True(Path.IsPathRooted(path)); } [Fact] - public void BasePath_ShouldBeAbsolute() + public void BasePath_ShouldUseConfiguration() { var builder = TestDistributedApplicationBuilder.Create(); - + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); var path = store.BasePath; - Assert.True(Path.IsPathRooted(path)); + Assert.DoesNotContain($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", path); + Assert.Contains(Path.GetTempPath(), path); } [Fact] - public void BasePath_ShouldUseConfiguration() + public void BasePath_ShouldBePrefixed_WhenUsingObjFolder() { var builder = TestDistributedApplicationBuilder.Create(); builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); var path = store.BasePath; - Assert.DoesNotContain($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", path); - Assert.Contains(Path.GetTempPath(), path); + Assert.Contains(".aspire", path); } [Fact] @@ -63,7 +62,6 @@ public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() var builder = TestDistributedApplicationBuilder.Create(); builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); builder.Configuration["AppHost:Sha256"] = "0123456789abcdef"; - var store = AspireStore.Create(builder); var path = store.BasePath; @@ -74,21 +72,23 @@ public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() } [Fact] - public void GetOrCreateFile_ShouldCreateFileIfNotExists() + public void GetFileName_ShouldNotCreateFile() { var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); var filename = "testfile1.txt"; var filePath = store.GetFileName(filename); - Assert.True(File.Exists(filePath)); + Assert.False(File.Exists(filePath)); } [Fact] public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() { var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); var filename = "testfile2.txt"; @@ -103,6 +103,7 @@ public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() public void GetOrCreateFileWithContent_ShouldCreateFile_WithFileContent() { var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); var filename = "testfile2.txt"; @@ -126,6 +127,7 @@ public void GetOrCreateFileWithContent_ShouldCreateFile_WithFileContent() public void GetOrCreateFileWithContent_ShouldNotRecreateFile() { var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); var store = AspireStore.Create(builder); var filename = "testfile3.txt"; @@ -144,9 +146,9 @@ public void GetOrCreateFileWithContent_ShouldNotRecreateFile() [Fact] public void Sanitize_ShouldRemoveInvalidCharacters() { - var invalidFilename = "inva|id:fi*le?name.t Date: Mon, 3 Feb 2025 18:48:17 -0800 Subject: [PATCH 17/19] Use temp path for store in functional tests --- .../AzureServiceBusExtensionsTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 4e012715ed..25bf27c8f9 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); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var healthCheckTcs = new TaskCompletionSource(); builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => @@ -231,6 +232,8 @@ public async Task VerifyAzureServiceBusEmulatorResource() var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(output); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() .WithQueue("queue123"); @@ -267,6 +270,8 @@ public async Task VerifyAzureServiceBusEmulatorResource() public void AddAzureServiceBusWithEmulatorGetsExpectedPort(int? port = null) { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder => { builder.WithHostPort(port); @@ -286,6 +291,7 @@ public void AddAzureServiceBusWithEmulatorGetsExpectedImageTag(string? imageTag) { using var builder = TestDistributedApplicationBuilder.Create(); var serviceBus = builder.AddAzureServiceBus("sb"); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); serviceBus.RunAsEmulator(container => { @@ -415,6 +421,7 @@ public async Task AzureServiceBusEmulatorResourceInitializesProvisioningModel() public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() @@ -551,6 +558,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedProperties() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() @@ -599,6 +607,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedP public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomizations() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator(configure => configure.ConfigureEmulator(document => @@ -639,6 +648,7 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz public async Task AzureServiceBusEmulator_WithConfigurationFile() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var configJsonPath = Path.GetTempFileName(); @@ -699,6 +709,7 @@ public async Task AzureServiceBusEmulator_WithConfigurationFile() public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent) { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var lifetime = isPersistent ? ContainerLifetime.Persistent : ContainerLifetime.Session; var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder => @@ -721,6 +732,7 @@ public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent) public void RunAsEmulator_CalledTwice_Throws() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(); Assert.Throws(() => serviceBus.RunAsEmulator()); From 21808c586a83d61e0abed8be2b098a11964a6b84 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 4 Feb 2025 13:45:51 -0800 Subject: [PATCH 18/19] PR feedback --- .../AzureServiceBusExtensions.cs | 31 +++- .../DistributedApplicationTestingBuilder.cs | 42 ++++- src/Aspire.Hosting/AspireStore.cs | 152 ++---------------- src/Aspire.Hosting/AspireStoreExtensions.cs | 49 ++++++ src/Aspire.Hosting/IAspireStore.cs | 49 ++++++ .../AzureServiceBusExtensionsTests.cs | 28 ++-- .../Aspire.Hosting.Tests/AspireStoreTests.cs | 58 ++----- 7 files changed, 198 insertions(+), 211 deletions(-) create mode 100644 src/Aspire.Hosting/AspireStoreExtensions.cs create mode 100644 src/Aspire.Hosting/IAspireStore.cs diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index c4b85d8415..675dea0927 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -292,7 +292,7 @@ public static IResourceBuilder RunAsEmulator(this IReso var lifetime = ContainerLifetime.Session; - var aspireStore = AspireStore.Create(builder.ApplicationBuilder); + var aspireStore = builder.ApplicationBuilder.CreateStore(); if (configureContainer != null) { @@ -330,20 +330,25 @@ public static IResourceBuilder RunAsEmulator(this IReso // Apply ConfigJsonAnnotation modifications var configJsonAnnotations = builder.Resource.Annotations.OfType(); - foreach (var annotation in configJsonAnnotations) + if (configJsonAnnotations.Any()) { using var readStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Read); var jsonObject = JsonNode.Parse(readStream); readStream.Close(); - using var writeStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Write); - using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true }); - if (jsonObject == null) { throw new InvalidOperationException("The configuration file mount could not be parsed."); } - annotation.Configure(jsonObject); + + foreach (var annotation in configJsonAnnotations) + { + + annotation.Configure(jsonObject); + } + + using var writeStream = new FileStream(tempConfigFile, FileMode.Open, FileAccess.Write); + using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true }); jsonObject.WriteTo(writer); } @@ -434,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); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 54194c9a16..8180b048de 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -274,16 +274,13 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - private sealed class TestingBuilder( - string[] args, - Action configureBuilder) : IDistributedApplicationTestingBuilder + private sealed class TestingBuilder : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder); + private readonly DistributedApplicationBuilder _innerBuilder; private DistributedApplication? _app; + private readonly string? _tempAspireStorePath; - private static DistributedApplicationBuilder CreateInnerBuilder( - string[] args, - Action configureBuilder) + public TestingBuilder(string[] args, Action configureBuilder) { var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) => { @@ -297,7 +294,12 @@ private static DistributedApplicationBuilder CreateInnerBuilder( builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); } - return builder; + if (!builder.Configuration.GetValue("Aspire:Store:Path", false)) + { + builder.Configuration["Aspire:Store:Path"] = _tempAspireStorePath = Path.GetTempPath(); + } + + _innerBuilder = builder; static Assembly FindApplicationAssembly() { @@ -373,6 +375,18 @@ public void Dispose() { app.Dispose(); } + + if (_tempAspireStorePath is { } path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch + { + // Suppress. + } + } } public async ValueTask DisposeAsync() @@ -393,6 +407,18 @@ public async ValueTask DisposeAsync() { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } + + if (_tempAspireStorePath is { } path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch + { + // Suppress. + } + } } } } diff --git a/src/Aspire.Hosting/AspireStore.cs b/src/Aspire.Hosting/AspireStore.cs index 338e950171..bc9dc008b1 100644 --- a/src/Aspire.Hosting/AspireStore.cs +++ b/src/Aspire.Hosting/AspireStore.cs @@ -1,33 +1,20 @@ // 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; using System.Security.Cryptography; 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 class AspireStore +internal sealed class AspireStore : IAspireStore { - internal const string AspireStorePathKeyName = "Aspire:Store:Path"; - private readonly string _basePath; /// /// Initializes a new instance of the class with the specified base path. /// /// The base path for the store. - private AspireStore(string basePath) + /// A new instance of . + public AspireStore(string basePath) { ArgumentNullException.ThrowIfNull(basePath); @@ -35,75 +22,11 @@ private AspireStore(string basePath) EnsureDirectory(); } - /// - /// Gets the base path of this store. - /// public string BasePath => _basePath; - /// - /// Creates a new instance of using the provided . - /// - /// The . - /// A new instance of . - public static AspireStore Create(IDistributedApplicationBuilder builder) + public string GetFileNameWithContent(string filenameTemplate, string sourceFilename) { - ArgumentNullException.ThrowIfNull(builder); - - var assemblyMetadata = builder.AppHostAssembly?.GetCustomAttributes(); - var objDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath"); - - var fallbackDir = builder.Configuration[AspireStorePathKeyName]; - - var root = fallbackDir ?? objDir; - - if (string.IsNullOrEmpty(root)) - { - throw new InvalidOperationException($"Could not determine an appropriate location for storing user secrets. Set the {AspireStorePathKeyName} setting to a folder where the App Host content should be stored."); - } - - var directoryPath = Path.Combine(root, ".aspire"); - - // The /obj directory doesn't need to be prefixed with the app host name. - if (root != objDir) - { - directoryPath = Path.Combine(directoryPath, GetAppHostSpecificPrefix(builder)); - } - - return new AspireStore(directoryPath); - } - - /// - /// 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; - - /// - /// Gets the application host specific prefix based on the builder's environment. - /// - /// The . - /// The application host specific prefix. - private static string GetAppHostSpecificPrefix(IDistributedApplicationBuilder builder) - { - var appName = Sanitize(builder.Environment.ApplicationName).ToLowerInvariant(); - var appNameHash = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant(); - return $"{appName}.{appNameHash}"; - } - - /// - /// 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. - public string GetFileNameWithContent(string filename, string sourceFilename) - { - ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); + ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate); ArgumentNullException.ThrowIfNullOrWhiteSpace(sourceFilename); if (!File.Exists(sourceFilename)) @@ -114,21 +37,7 @@ public string GetFileNameWithContent(string filename, string sourceFilename) EnsureDirectory(); // Strip any folder information from the filename. - filename = Path.GetFileName(filename); - - // Delete existing file versions with the same name. - var allFiles = Directory.EnumerateFiles(_basePath, filename + ".*"); - - foreach (var file in allFiles) - { - try - { - File.Delete(file); - } - catch - { - } - } + filenameTemplate = Path.GetFileName(filenameTemplate); var hashStream = File.OpenRead(sourceFilename); @@ -137,9 +46,9 @@ public string GetFileNameWithContent(string filename, string sourceFilename) hashStream.Dispose(); - var name = Path.GetFileNameWithoutExtension(filename); - var ext = Path.GetExtension(filename); - var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}".ToLowerInvariant()); + 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)) { @@ -149,16 +58,9 @@ public string GetFileNameWithContent(string filename, string sourceFilename) return finalFilePath; } - /// - /// 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. - public string GetFileNameWithContent(string filename, Stream contentStream) + public string GetFileNameWithContent(string filenameTemplate, Stream contentStream) { - ArgumentNullException.ThrowIfNullOrWhiteSpace(filename); + ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate); ArgumentNullException.ThrowIfNull(contentStream); // Create a temporary file to write the content to. @@ -170,7 +72,7 @@ public string GetFileNameWithContent(string filename, Stream contentStream) contentStream.CopyTo(fileStream); } - var finalFilePath = GetFileNameWithContent(filename, tempFileName); + var finalFilePath = GetFileNameWithContent(filenameTemplate, tempFileName); try { @@ -183,11 +85,6 @@ public string GetFileNameWithContent(string filename, Stream contentStream) return finalFilePath; } - /// - /// Creates an absolute file name for the provided in the store. - /// - /// The file name to use in the store. - /// The absolute file name in the store. public string GetFileName(string filename) { EnsureDirectory(); @@ -198,35 +95,12 @@ public string GetFileName(string filename) return Path.Combine(_basePath, filename); } - /// - /// Removes any unwanted characters from the . - /// - /// The filename to sanitize. - /// The sanitized filename. - internal static string Sanitize(string filename) - { - ArgumentException.ThrowIfNullOrEmpty(filename); - - return string.Create(filename.Length, filename, static (s, name) => - { - // First char must be a letter of digit - s[0] = char.IsAsciiLetterOrDigit(name[0]) ? name[0] : '_'; - - for (var i = 1; i < name.Length; i++) - { - var c = name[i]; - - s[i] = char.IsAsciiLetterOrDigit(c) || c == '.' ? c : '_'; - } - }); - } - /// /// Ensures that the directory for the store exists. /// private void EnsureDirectory() { - if (!string.IsNullOrEmpty(_basePath) && !Directory.Exists(_basePath)) + 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..d40a93ea8d --- /dev/null +++ b/src/Aspire.Hosting/IAspireStore.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. + +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; } + + /// + /// Creates an absolute file name for the provided in the store. + /// + /// The file name to use in the store. + /// The absolute file name in the store. + string GetFileName(string filename); + + /// + /// 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/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs index 25bf27c8f9..7803fd043f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs @@ -188,7 +188,7 @@ public async Task VerifyWaitForOnServiceBusEmulatorBlocksDependentResources() { var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); using var builder = TestDistributedApplicationBuilder.Create(output); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + var healthCheckTcs = new TaskCompletionSource(); builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => @@ -232,7 +232,6 @@ public async Task VerifyAzureServiceBusEmulatorResource() var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(output); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() @@ -270,7 +269,6 @@ public async Task VerifyAzureServiceBusEmulatorResource() public void AddAzureServiceBusWithEmulatorGetsExpectedPort(int? port = null) { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder => { @@ -291,7 +289,6 @@ public void AddAzureServiceBusWithEmulatorGetsExpectedImageTag(string? imageTag) { using var builder = TestDistributedApplicationBuilder.Create(); var serviceBus = builder.AddAzureServiceBus("sb"); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); serviceBus.RunAsEmulator(container => { @@ -421,7 +418,6 @@ public async Task AzureServiceBusEmulatorResourceInitializesProvisioningModel() public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() @@ -558,7 +554,6 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJson() public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedProperties() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var serviceBus = builder.AddAzureServiceBus("servicebusns") .RunAsEmulator() @@ -607,13 +602,18 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedP public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomizations() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); 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(); @@ -636,7 +636,8 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz "Logging": { "Type": "Console" } - } + }, + "Custom": 42 } """, configJsonContent); @@ -648,7 +649,6 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz public async Task AzureServiceBusEmulator_WithConfigurationFile() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var configJsonPath = Path.GetTempFileName(); @@ -709,7 +709,6 @@ public async Task AzureServiceBusEmulator_WithConfigurationFile() public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent) { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); var lifetime = isPersistent ? ContainerLifetime.Persistent : ContainerLifetime.Session; var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder => @@ -732,7 +731,6 @@ public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent) public void RunAsEmulator_CalledTwice_Throws() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); 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 index 732b3f5d53..496f018697 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -11,9 +11,7 @@ public class AspireStoreTests [Fact] public void Create_ShouldInitializeStore() { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + var store = CreateStore(); Assert.NotNull(store); Assert.True(Directory.Exists(Path.GetDirectoryName(store.BasePath))); @@ -22,9 +20,7 @@ public void Create_ShouldInitializeStore() [Fact] public void BasePath_ShouldBeAbsolute() { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + var store = CreateStore(); var path = store.BasePath; @@ -35,8 +31,8 @@ public void BasePath_ShouldBeAbsolute() public void BasePath_ShouldUseConfiguration() { var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); + var store = builder.CreateStore(); var path = store.BasePath; @@ -44,39 +40,20 @@ public void BasePath_ShouldUseConfiguration() Assert.Contains(Path.GetTempPath(), path); } - [Fact] - public void BasePath_ShouldBePrefixed_WhenUsingObjFolder() - { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); - - var path = store.BasePath; - - Assert.Contains(".aspire", path); - } - [Fact] public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - builder.Configuration["AppHost:Sha256"] = "0123456789abcdef"; - var store = AspireStore.Create(builder); + var store = CreateStore(); var path = store.BasePath; - Assert.Contains(builder.Environment.ApplicationName.ToLowerInvariant(), path); - Assert.Contains("0123456789", path); Assert.Contains(".aspire", path); } [Fact] public void GetFileName_ShouldNotCreateFile() { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + var store = CreateStore(); var filename = "testfile1.txt"; var filePath = store.GetFileName(filename); @@ -88,8 +65,8 @@ public void GetFileName_ShouldNotCreateFile() public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() { var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + 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")); @@ -102,9 +79,7 @@ public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() [Fact] public void GetOrCreateFileWithContent_ShouldCreateFile_WithFileContent() { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + var store = CreateStore(); var filename = "testfile2.txt"; var tempFilename = Path.GetTempFileName(); @@ -126,9 +101,7 @@ public void GetOrCreateFileWithContent_ShouldCreateFile_WithFileContent() [Fact] public void GetOrCreateFileWithContent_ShouldNotRecreateFile() { - var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); - var store = AspireStore.Create(builder); + var store = CreateStore(); var filename = "testfile3.txt"; var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); @@ -143,12 +116,11 @@ public void GetOrCreateFileWithContent_ShouldNotRecreateFile() Assert.Equal("updated", content2); } - [Fact] - public void Sanitize_ShouldRemoveInvalidCharacters() + private static IAspireStore CreateStore() { - var invalidFilename = "..inva|id:fi*le?name.t Date: Tue, 4 Feb 2025 15:23:08 -0800 Subject: [PATCH 19/19] Moving things --- .../AzureServiceBusExtensions.cs | 10 ++--- .../DistributedApplicationTestingBuilder.cs | 42 ++++--------------- src/Aspire.Hosting/AspireStore.cs | 10 ----- src/Aspire.Hosting/IAspireStore.cs | 7 ---- .../Aspire.Hosting.Tests/AspireStoreTests.cs | 11 ----- ...utedApplicationTestingBuilderExtensions.cs | 7 +++- .../TestDistributedApplicationBuilder.cs | 2 + 7 files changed, 21 insertions(+), 68 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 675dea0927..53f8ac9458 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -287,13 +287,8 @@ public static IResourceBuilder RunAsEmulator(this IReso context.EnvironmentVariables.Add("MSSQL_SA_PASSWORD", passwordParameter); })); - ServiceBusClient? serviceBusClient = null; - string? queueOrTopicName = null; - var lifetime = ContainerLifetime.Session; - var aspireStore = builder.ApplicationBuilder.CreateStore(); - if (configureContainer != null) { var surrogate = new AzureServiceBusEmulatorResource(builder.Resource); @@ -352,6 +347,8 @@ public static IResourceBuilder RunAsEmulator(this IReso jsonObject.WriteTo(writer); } + var aspireStore = builder.ApplicationBuilder.CreateStore(); + // Deterministic file path for the configuration file based on its content var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); @@ -375,6 +372,9 @@ public static IResourceBuilder RunAsEmulator(this IReso return Task.CompletedTask; }); + ServiceBusClient? serviceBusClient = null; + string? queueOrTopicName = null; + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 8180b048de..54194c9a16 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -274,13 +274,16 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - private sealed class TestingBuilder : IDistributedApplicationTestingBuilder + private sealed class TestingBuilder( + string[] args, + Action configureBuilder) : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder; + private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder); private DistributedApplication? _app; - private readonly string? _tempAspireStorePath; - public TestingBuilder(string[] args, Action configureBuilder) + private static DistributedApplicationBuilder CreateInnerBuilder( + string[] args, + Action configureBuilder) { var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) => { @@ -294,12 +297,7 @@ public TestingBuilder(string[] args, Action http.AddStandardResilienceHandler()); } - if (!builder.Configuration.GetValue("Aspire:Store:Path", false)) - { - builder.Configuration["Aspire:Store:Path"] = _tempAspireStorePath = Path.GetTempPath(); - } - - _innerBuilder = builder; + return builder; static Assembly FindApplicationAssembly() { @@ -375,18 +373,6 @@ public void Dispose() { app.Dispose(); } - - if (_tempAspireStorePath is { } path) - { - try - { - Directory.Delete(path, recursive: true); - } - catch - { - // Suppress. - } - } } public async ValueTask DisposeAsync() @@ -407,18 +393,6 @@ public async ValueTask DisposeAsync() { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } - - if (_tempAspireStorePath is { } path) - { - try - { - Directory.Delete(path, recursive: true); - } - catch - { - // Suppress. - } - } } } } diff --git a/src/Aspire.Hosting/AspireStore.cs b/src/Aspire.Hosting/AspireStore.cs index bc9dc008b1..8c682cae11 100644 --- a/src/Aspire.Hosting/AspireStore.cs +++ b/src/Aspire.Hosting/AspireStore.cs @@ -85,16 +85,6 @@ public string GetFileNameWithContent(string filenameTemplate, Stream contentStre return finalFilePath; } - public string GetFileName(string filename) - { - EnsureDirectory(); - - // Strip any folder information from the filename. - filename = Path.GetFileName(filename); - - return Path.Combine(_basePath, filename); - } - /// /// Ensures that the directory for the store exists. /// diff --git a/src/Aspire.Hosting/IAspireStore.cs b/src/Aspire.Hosting/IAspireStore.cs index d40a93ea8d..f4c019256a 100644 --- a/src/Aspire.Hosting/IAspireStore.cs +++ b/src/Aspire.Hosting/IAspireStore.cs @@ -21,13 +21,6 @@ public interface IAspireStore /// string BasePath { get; } - /// - /// Creates an absolute file name for the provided in the store. - /// - /// The file name to use in the store. - /// The absolute file name in the store. - string GetFileName(string filename); - /// /// 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. diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs index 496f018697..29c5411c5a 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -50,17 +50,6 @@ public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() Assert.Contains(".aspire", path); } - [Fact] - public void GetFileName_ShouldNotCreateFile() - { - var store = CreateStore(); - - var filename = "testfile1.txt"; - var filePath = store.GetFileName(filename); - - Assert.False(File.Exists(filePath)); - } - [Fact] public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() { 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; } }