diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 19054b3d3b9..d22f612ec2d 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -317,7 +317,7 @@ public static IResourceBuilder RunAsEmulator(this IResou jsonObject.WriteTo(writer); } - var aspireStore = builder.ApplicationBuilder.CreateStore(); + var aspireStore = @event.Services.GetRequiredService(); // Deterministic file path for the configuration file based on its content var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index d9fd04d4600..9861a742293 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -418,7 +418,7 @@ public static IResourceBuilder RunAsEmulator(this IReso jsonObject.WriteTo(writer); } - var aspireStore = builder.ApplicationBuilder.CreateStore(); + var aspireStore = @event.Services.GetRequiredService(); // Deterministic file path for the configuration file based on its content var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile); diff --git a/src/Aspire.Hosting/ApplicationModel/AspireStore.cs b/src/Aspire.Hosting/ApplicationModel/AspireStore.cs index 2c72050df88..c2d976bd17b 100644 --- a/src/Aspire.Hosting/ApplicationModel/AspireStore.cs +++ b/src/Aspire.Hosting/ApplicationModel/AspireStore.cs @@ -1,12 +1,15 @@ // 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.IO.Hashing; +using Aspire.Hosting.Utils; namespace Aspire.Hosting.ApplicationModel; internal sealed class AspireStore : IAspireStore { + internal const string AspireStorePathKeyName = "Aspire:Store:Path"; + private readonly string _basePath; /// @@ -29,56 +32,38 @@ public AspireStore(string basePath) public string BasePath => _basePath; - public string GetFileNameWithContent(string filenameTemplate, string sourceFilename) + public string GetFileNameWithContent(string filenameTemplate, Stream contentStream) { ArgumentException.ThrowIfNullOrWhiteSpace(filenameTemplate); - ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilename); - - if (!File.Exists(sourceFilename)) - { - throw new FileNotFoundException("The source file does not exist.", sourceFilename); - } + ArgumentNullException.ThrowIfNull(contentStream); EnsureDirectory(); // Strip any folder information from the filename. filenameTemplate = Path.GetFileName(filenameTemplate); - var hashStream = File.OpenRead(sourceFilename); + // Create a temporary file to write the content to. + var tempFileName = Path.GetTempFileName(); - // Compute the hash of the content. - var hash = SHA256.HashData(hashStream); + // Fast, non-cryptographic hash. + var hash = new XxHash3(); - hashStream.Dispose(); + // Write the content to the temporary file while also building a hash. + using (var fileStream = File.OpenWrite(tempFileName)) + { + using var digestStream = new HashDigestStream(fileStream, hash); + contentStream.CopyTo(digestStream); + } var name = Path.GetFileNameWithoutExtension(filenameTemplate); var ext = Path.GetExtension(filenameTemplate); - var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}"); + var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash.GetCurrentHash())[..12].ToLowerInvariant()}{ext}"); if (!File.Exists(finalFilePath)) { - File.Copy(sourceFilename, finalFilePath, overwrite: true); + File.Copy(tempFileName, finalFilePath, overwrite: true); } - return finalFilePath; - } - - public string GetFileNameWithContent(string filenameTemplate, Stream contentStream) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filenameTemplate); - ArgumentNullException.ThrowIfNull(contentStream); - - // Create a temporary file to write the content to. - var tempFileName = Path.GetTempFileName(); - - // Write the content to the temporary file. - using (var fileStream = File.OpenWrite(tempFileName)) - { - contentStream.CopyTo(fileStream); - } - - var finalFilePath = GetFileNameWithContent(filenameTemplate, tempFileName); - try { File.Delete(tempFileName); diff --git a/src/Aspire.Hosting/ApplicationModel/AspireStoreExtensions.cs b/src/Aspire.Hosting/ApplicationModel/AspireStoreExtensions.cs index 05c83b110dd..4ae228c84d9 100644 --- a/src/Aspire.Hosting/ApplicationModel/AspireStoreExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/AspireStoreExtensions.cs @@ -1,8 +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; - namespace Aspire.Hosting.ApplicationModel; /// @@ -10,40 +8,27 @@ namespace Aspire.Hosting.ApplicationModel; /// public static class AspireStoreExtensions { - internal const string AspireStorePathKeyName = "Aspire:Store:Path"; - /// - /// Creates a new App Host store using the provided . + /// Gets a deterministic file path that is a copy of the . + /// The resulting file name will depend on the content of the file. /// - /// The . - /// The . - public static IAspireStore CreateStore(this IDistributedApplicationBuilder builder) + /// The instance. + /// 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 static string GetFileNameWithContent(this IAspireStore aspireStore, string filenameTemplate, string sourceFilename) { - ArgumentNullException.ThrowIfNull(builder); - - var aspireDir = builder.Configuration[AspireStorePathKeyName]; + ArgumentException.ThrowIfNullOrWhiteSpace(filenameTemplate); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilename); - if (string.IsNullOrWhiteSpace(aspireDir)) + if (!File.Exists(sourceFilename)) { - 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."); - } + throw new FileNotFoundException("The source file does not exist.", sourceFilename); } - 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; + using var sourceStream = File.OpenRead(sourceFilename); + return aspireStore.GetFileNameWithContent(filenameTemplate, sourceStream); + } } diff --git a/src/Aspire.Hosting/ApplicationModel/IAspireStore.cs b/src/Aspire.Hosting/ApplicationModel/IAspireStore.cs index 14ecc83df1b..e47149206a4 100644 --- a/src/Aspire.Hosting/ApplicationModel/IAspireStore.cs +++ b/src/Aspire.Hosting/ApplicationModel/IAspireStore.cs @@ -29,14 +29,4 @@ public interface IAspireStore /// A stream containing the content. /// A deterministic file path with the same content as the provided stream. string GetFileNameWithContent(string filenameTemplate, Stream contentStream); - - /// - /// Gets a deterministic file path that is a copy of the . - /// The resulting file name will depend on the content of the file. - /// - /// A file name to base the result on. - /// An existing file. - /// A deterministic file path with the same content as . - /// Thrown when the source file does not exist. - string GetFileNameWithContent(string filenameTemplate, string sourceFilename); } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 6dd7888b010..2210c21f48f 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -50,6 +50,7 @@ + diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 3c0b26e1c65..e986f57dd5b 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -156,6 +156,9 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) var appHostName = options.ProjectName ?? _innerBuilder.Environment.ApplicationName; AppHostPath = Path.Join(AppHostDirectory, appHostName); + var assemblyMetadata = AppHostAssembly?.GetCustomAttributes(); + var aspireDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath"); + // Set configuration ConfigurePublishingOptions(options); _innerBuilder.Configuration.AddInMemoryCollection(new Dictionary @@ -163,6 +166,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Make the app host directory available to the application via configuration ["AppHost:Directory"] = AppHostDirectory, ["AppHost:Path"] = AppHostPath, + [AspireStore.AspireStorePathKeyName] = aspireDir }); _executionContextOptions = _innerBuilder.Configuration["Publishing:Publisher"] switch @@ -208,6 +212,18 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // from a failure in that case. o.DefaultWaitBehavior = options.DisableDashboard ? WaitBehavior.StopOnResourceUnavailable : WaitBehavior.WaitOnResourceUnavailable; }); + _innerBuilder.Services.AddSingleton(sp => + { + var configuration = sp.GetRequiredService(); + var aspireDir = configuration[AspireStore.AspireStorePathKeyName]; + + if (string.IsNullOrWhiteSpace(aspireDir)) + { + throw new InvalidOperationException($"Could not determine an appropriate location for local storage. Set the {AspireStore.AspireStorePathKeyName} setting to a folder where the App Host content should be stored."); + } + + return new AspireStore(Path.Combine(aspireDir, ".aspire")); + }); ConfigureHealthChecks(); @@ -506,4 +522,14 @@ private static DiagnosticListener LogAppBuilt(DistributedApplication app) return diagnosticListener; } + + /// + /// 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/Utils/HashDigestStream.cs b/src/Aspire.Hosting/Utils/HashDigestStream.cs new file mode 100644 index 00000000000..42304c0a106 --- /dev/null +++ b/src/Aspire.Hosting/Utils/HashDigestStream.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Hashing; + +namespace Aspire.Hosting.Utils; + +/// +/// A stream capable of computing the hash digest of raw data while also copying it. +/// +internal sealed class HashDigestStream : Stream +{ + private readonly Stream _writeStream; + private readonly NonCryptographicHashAlgorithm _hashAlgorithm; + + public HashDigestStream(Stream writeStream, NonCryptographicHashAlgorithm hashAlgorithm) + { + _writeStream = writeStream; + _hashAlgorithm = hashAlgorithm; + } + + public override bool CanWrite => true; + + public override void Write(byte[] buffer, int offset, int count) + { + _hashAlgorithm.Append(buffer.AsSpan(offset, count)); + _writeStream.Write(buffer, offset, count); + } + + public override void Flush() + { + _writeStream.Flush(); + } + + // This should not be used by Stream.CopyTo(Stream) + public override void Write(ReadOnlySpan buffer) + => throw new NotImplementedException(); + + // This class is never used with async writes, but if it ever is, implement these overrides + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotImplementedException(); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public override bool CanRead => false; + public override bool CanSeek => false; + public override long Length => throw new NotImplementedException(); + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotImplementedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotImplementedException(); + public override void SetLength(long value) => throw new NotImplementedException(); +} diff --git a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs index 8643249fa61..a01f7127d05 100644 --- a/tests/Aspire.Hosting.Tests/AspireStoreTests.cs +++ b/tests/Aspire.Hosting.Tests/AspireStoreTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Aspire.Hosting.Tests; @@ -31,8 +32,9 @@ public void BasePath_ShouldBeAbsolute() public void BasePath_ShouldUseConfiguration() { var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); - var store = builder.CreateStore(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); + var app = builder.Build(); + var store = app.Services.GetRequiredService(); var path = store.BasePath; @@ -54,8 +56,9 @@ public void BasePath_ShouldBePrefixed_WhenUsingConfiguration() public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent() { var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); - var store = builder.CreateStore(); + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); + var app = builder.Build(); + var store = app.Services.GetRequiredService(); var filename = "testfile2.txt"; var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content")); @@ -127,8 +130,8 @@ public void AspireStoreConstructor_ShouldThrow_IfNotAbsolutePath(string? basePat private static IAspireStore CreateStore() { var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath(); - var store = builder.CreateStore(); - return store; + builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath(); + var app = builder.Build(); + return app.Services.GetRequiredService(); } }