Skip to content

Commit c8ded63

Browse files
Refactor AspireStore APIs (#7626)
Reacting to https://github.com/dotnet/aspire/pull/7374/files#r1950365868 - Simplified IAspireStore by moving one method to extensions - Retrieve IAspireStore from DI - Refactoring stream code to write to the file while also computing the hash at the same time --------- Co-authored-by: Eric Erhardt <[email protected]>
1 parent 5126607 commit c8ded63

File tree

9 files changed

+125
-82
lines changed

9 files changed

+125
-82
lines changed

src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou
317317
jsonObject.WriteTo(writer);
318318
}
319319

320-
var aspireStore = builder.ApplicationBuilder.CreateStore();
320+
var aspireStore = @event.Services.GetRequiredService<IAspireStore>();
321321

322322
// Deterministic file path for the configuration file based on its content
323323
var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile);

src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ public static IResourceBuilder<AzureServiceBusResource> RunAsEmulator(this IReso
418418
jsonObject.WriteTo(writer);
419419
}
420420

421-
var aspireStore = builder.ApplicationBuilder.CreateStore();
421+
var aspireStore = @event.Services.GetRequiredService<IAspireStore>();
422422

423423
// Deterministic file path for the configuration file based on its content
424424
var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile);

src/Aspire.Hosting/ApplicationModel/AspireStore.cs

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Security.Cryptography;
4+
using System.IO.Hashing;
5+
using Aspire.Hosting.Utils;
56

67
namespace Aspire.Hosting.ApplicationModel;
78

89
internal sealed class AspireStore : IAspireStore
910
{
11+
internal const string AspireStorePathKeyName = "Aspire:Store:Path";
12+
1013
private readonly string _basePath;
1114

1215
/// <summary>
@@ -29,56 +32,38 @@ public AspireStore(string basePath)
2932

3033
public string BasePath => _basePath;
3134

32-
public string GetFileNameWithContent(string filenameTemplate, string sourceFilename)
35+
public string GetFileNameWithContent(string filenameTemplate, Stream contentStream)
3336
{
3437
ArgumentException.ThrowIfNullOrWhiteSpace(filenameTemplate);
35-
ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilename);
36-
37-
if (!File.Exists(sourceFilename))
38-
{
39-
throw new FileNotFoundException("The source file does not exist.", sourceFilename);
40-
}
38+
ArgumentNullException.ThrowIfNull(contentStream);
4139

4240
EnsureDirectory();
4341

4442
// Strip any folder information from the filename.
4543
filenameTemplate = Path.GetFileName(filenameTemplate);
4644

47-
var hashStream = File.OpenRead(sourceFilename);
45+
// Create a temporary file to write the content to.
46+
var tempFileName = Path.GetTempFileName();
4847

49-
// Compute the hash of the content.
50-
var hash = SHA256.HashData(hashStream);
48+
// Fast, non-cryptographic hash.
49+
var hash = new XxHash3();
5150

52-
hashStream.Dispose();
51+
// Write the content to the temporary file while also building a hash.
52+
using (var fileStream = File.OpenWrite(tempFileName))
53+
{
54+
using var digestStream = new HashDigestStream(fileStream, hash);
55+
contentStream.CopyTo(digestStream);
56+
}
5357

5458
var name = Path.GetFileNameWithoutExtension(filenameTemplate);
5559
var ext = Path.GetExtension(filenameTemplate);
56-
var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}");
60+
var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash.GetCurrentHash())[..12].ToLowerInvariant()}{ext}");
5761

5862
if (!File.Exists(finalFilePath))
5963
{
60-
File.Copy(sourceFilename, finalFilePath, overwrite: true);
64+
File.Copy(tempFileName, finalFilePath, overwrite: true);
6165
}
6266

63-
return finalFilePath;
64-
}
65-
66-
public string GetFileNameWithContent(string filenameTemplate, Stream contentStream)
67-
{
68-
ArgumentException.ThrowIfNullOrWhiteSpace(filenameTemplate);
69-
ArgumentNullException.ThrowIfNull(contentStream);
70-
71-
// Create a temporary file to write the content to.
72-
var tempFileName = Path.GetTempFileName();
73-
74-
// Write the content to the temporary file.
75-
using (var fileStream = File.OpenWrite(tempFileName))
76-
{
77-
contentStream.CopyTo(fileStream);
78-
}
79-
80-
var finalFilePath = GetFileNameWithContent(filenameTemplate, tempFileName);
81-
8267
try
8368
{
8469
File.Delete(tempFileName);
Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,34 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Reflection;
5-
64
namespace Aspire.Hosting.ApplicationModel;
75

86
/// <summary>
97
/// Provides extension methods for <see cref="IDistributedApplicationBuilder"/> to create an <see cref="IAspireStore"/> instance.
108
/// </summary>
119
public static class AspireStoreExtensions
1210
{
13-
internal const string AspireStorePathKeyName = "Aspire:Store:Path";
14-
1511
/// <summary>
16-
/// Creates a new App Host store using the provided <paramref name="builder"/>.
12+
/// Gets a deterministic file path that is a copy of the <paramref name="sourceFilename"/>.
13+
/// The resulting file name will depend on the content of the file.
1714
/// </summary>
18-
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
19-
/// <returns>The <see cref="IAspireStore"/>.</returns>
20-
public static IAspireStore CreateStore(this IDistributedApplicationBuilder builder)
15+
/// <param name="aspireStore">The <see cref="IAspireStore"/> instance.</param>
16+
/// <param name="filenameTemplate">A file name to base the result on.</param>
17+
/// <param name="sourceFilename">An existing file.</param>
18+
/// <returns>A deterministic file path with the same content as <paramref name="sourceFilename"/>.</returns>
19+
/// <exception cref="FileNotFoundException">Thrown when the source file does not exist.</exception>
20+
public static string GetFileNameWithContent(this IAspireStore aspireStore, string filenameTemplate, string sourceFilename)
2121
{
22-
ArgumentNullException.ThrowIfNull(builder);
23-
24-
var aspireDir = builder.Configuration[AspireStorePathKeyName];
22+
ArgumentException.ThrowIfNullOrWhiteSpace(filenameTemplate);
23+
ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilename);
2524

26-
if (string.IsNullOrWhiteSpace(aspireDir))
25+
if (!File.Exists(sourceFilename))
2726
{
28-
var assemblyMetadata = builder.AppHostAssembly?.GetCustomAttributes<AssemblyMetadataAttribute>();
29-
aspireDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath");
30-
31-
if (string.IsNullOrWhiteSpace(aspireDir))
32-
{
33-
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.");
34-
}
27+
throw new FileNotFoundException("The source file does not exist.", sourceFilename);
3528
}
3629

37-
return new AspireStore(Path.Combine(aspireDir, ".aspire"));
38-
}
39-
40-
/// <summary>
41-
/// Gets the metadata value for the specified key from the assembly metadata.
42-
/// </summary>
43-
/// <param name="assemblyMetadata">The assembly metadata.</param>
44-
/// <param name="key">The key to look for.</param>
45-
/// <returns>The metadata value if found; otherwise, null.</returns>
46-
private static string? GetMetadataValue(IEnumerable<AssemblyMetadataAttribute>? assemblyMetadata, string key) =>
47-
assemblyMetadata?.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase))?.Value;
30+
using var sourceStream = File.OpenRead(sourceFilename);
4831

32+
return aspireStore.GetFileNameWithContent(filenameTemplate, sourceStream);
33+
}
4934
}

src/Aspire.Hosting/ApplicationModel/IAspireStore.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,4 @@ public interface IAspireStore
2929
/// <param name="contentStream">A stream containing the content.</param>
3030
/// <returns>A deterministic file path with the same content as the provided stream.</returns>
3131
string GetFileNameWithContent(string filenameTemplate, Stream contentStream);
32-
33-
/// <summary>
34-
/// Gets a deterministic file path that is a copy of the <paramref name="sourceFilename"/>.
35-
/// The resulting file name will depend on the content of the file.
36-
/// </summary>
37-
/// <param name="filenameTemplate">A file name to base the result on.</param>
38-
/// <param name="sourceFilename">An existing file.</param>
39-
/// <returns>A deterministic file path with the same content as <paramref name="sourceFilename"/>.</returns>
40-
/// <exception cref="FileNotFoundException">Thrown when the source file does not exist.</exception>
41-
string GetFileNameWithContent(string filenameTemplate, string sourceFilename);
4232
}

src/Aspire.Hosting/Aspire.Hosting.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
<PackageReference Include="Polly.Core" />
5151
<PackageReference Include="JsonPatch.Net" />
5252
<PackageReference Include="AspNetCore.HealthChecks.Uris" />
53+
<PackageReference Include="System.IO.Hashing" />
5354
</ItemGroup>
5455

5556
<ItemGroup>

src/Aspire.Hosting/DistributedApplicationBuilder.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,17 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
156156
var appHostName = options.ProjectName ?? _innerBuilder.Environment.ApplicationName;
157157
AppHostPath = Path.Join(AppHostDirectory, appHostName);
158158

159+
var assemblyMetadata = AppHostAssembly?.GetCustomAttributes<AssemblyMetadataAttribute>();
160+
var aspireDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath");
161+
159162
// Set configuration
160163
ConfigurePublishingOptions(options);
161164
_innerBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
162165
{
163166
// Make the app host directory available to the application via configuration
164167
["AppHost:Directory"] = AppHostDirectory,
165168
["AppHost:Path"] = AppHostPath,
169+
[AspireStore.AspireStorePathKeyName] = aspireDir
166170
});
167171

168172
_executionContextOptions = _innerBuilder.Configuration["Publishing:Publisher"] switch
@@ -208,6 +212,18 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
208212
// from a failure in that case.
209213
o.DefaultWaitBehavior = options.DisableDashboard ? WaitBehavior.StopOnResourceUnavailable : WaitBehavior.WaitOnResourceUnavailable;
210214
});
215+
_innerBuilder.Services.AddSingleton<IAspireStore, AspireStore>(sp =>
216+
{
217+
var configuration = sp.GetRequiredService<IConfiguration>();
218+
var aspireDir = configuration[AspireStore.AspireStorePathKeyName];
219+
220+
if (string.IsNullOrWhiteSpace(aspireDir))
221+
{
222+
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.");
223+
}
224+
225+
return new AspireStore(Path.Combine(aspireDir, ".aspire"));
226+
});
211227

212228
ConfigureHealthChecks();
213229

@@ -506,4 +522,14 @@ private static DiagnosticListener LogAppBuilt(DistributedApplication app)
506522

507523
return diagnosticListener;
508524
}
525+
526+
/// <summary>
527+
/// Gets the metadata value for the specified key from the assembly metadata.
528+
/// </summary>
529+
/// <param name="assemblyMetadata">The assembly metadata.</param>
530+
/// <param name="key">The key to look for.</param>
531+
/// <returns>The metadata value if found; otherwise, null.</returns>
532+
private static string? GetMetadataValue(IEnumerable<AssemblyMetadataAttribute>? assemblyMetadata, string key) =>
533+
assemblyMetadata?.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase))?.Value;
534+
509535
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.IO.Hashing;
5+
6+
namespace Aspire.Hosting.Utils;
7+
8+
/// <summary>
9+
/// A stream capable of computing the hash digest of raw data while also copying it.
10+
/// </summary>
11+
internal sealed class HashDigestStream : Stream
12+
{
13+
private readonly Stream _writeStream;
14+
private readonly NonCryptographicHashAlgorithm _hashAlgorithm;
15+
16+
public HashDigestStream(Stream writeStream, NonCryptographicHashAlgorithm hashAlgorithm)
17+
{
18+
_writeStream = writeStream;
19+
_hashAlgorithm = hashAlgorithm;
20+
}
21+
22+
public override bool CanWrite => true;
23+
24+
public override void Write(byte[] buffer, int offset, int count)
25+
{
26+
_hashAlgorithm.Append(buffer.AsSpan(offset, count));
27+
_writeStream.Write(buffer, offset, count);
28+
}
29+
30+
public override void Flush()
31+
{
32+
_writeStream.Flush();
33+
}
34+
35+
// This should not be used by Stream.CopyTo(Stream)
36+
public override void Write(ReadOnlySpan<byte> buffer)
37+
=> throw new NotImplementedException();
38+
39+
// This class is never used with async writes, but if it ever is, implement these overrides
40+
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
41+
=> throw new NotImplementedException();
42+
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken)
43+
=> throw new NotImplementedException();
44+
45+
public override bool CanRead => false;
46+
public override bool CanSeek => false;
47+
public override long Length => throw new NotImplementedException();
48+
public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
49+
50+
public override int Read(byte[] buffer, int offset, int count) => throw new NotImplementedException();
51+
public override long Seek(long offset, SeekOrigin origin) => throw new NotImplementedException();
52+
public override void SetLength(long value) => throw new NotImplementedException();
53+
}

tests/Aspire.Hosting.Tests/AspireStoreTests.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using Aspire.Hosting.Utils;
5+
using Microsoft.Extensions.DependencyInjection;
56
using Xunit;
67

78
namespace Aspire.Hosting.Tests;
@@ -31,8 +32,9 @@ public void BasePath_ShouldBeAbsolute()
3132
public void BasePath_ShouldUseConfiguration()
3233
{
3334
var builder = TestDistributedApplicationBuilder.Create();
34-
builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath();
35-
var store = builder.CreateStore();
35+
builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath();
36+
var app = builder.Build();
37+
var store = app.Services.GetRequiredService<IAspireStore>();
3638

3739
var path = store.BasePath;
3840

@@ -54,8 +56,9 @@ public void BasePath_ShouldBePrefixed_WhenUsingConfiguration()
5456
public void GetOrCreateFileWithContent_ShouldCreateFile_WithStreamContent()
5557
{
5658
var builder = TestDistributedApplicationBuilder.Create();
57-
builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath();
58-
var store = builder.CreateStore();
59+
builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath();
60+
var app = builder.Build();
61+
var store = app.Services.GetRequiredService<IAspireStore>();
5962

6063
var filename = "testfile2.txt";
6164
var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content"));
@@ -127,8 +130,8 @@ public void AspireStoreConstructor_ShouldThrow_IfNotAbsolutePath(string? basePat
127130
private static IAspireStore CreateStore()
128131
{
129132
var builder = TestDistributedApplicationBuilder.Create();
130-
builder.Configuration[AspireStoreExtensions.AspireStorePathKeyName] = Path.GetTempPath();
131-
var store = builder.CreateStore();
132-
return store;
133+
builder.Configuration[AspireStore.AspireStorePathKeyName] = Path.GetTempPath();
134+
var app = builder.Build();
135+
return app.Services.GetRequiredService<IAspireStore>();
133136
}
134137
}

0 commit comments

Comments
 (0)