Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Refactor AspireStore APIs #7626

Merged
merged 8 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou
jsonObject.WriteTo(writer);
}

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

// Deterministic file path for the configuration file based on its content
var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ public static IResourceBuilder<AzureServiceBusResource> RunAsEmulator(this IReso
jsonObject.WriteTo(writer);
}

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

// Deterministic file path for the configuration file based on its content
var configJsonPath = aspireStore.GetFileNameWithContent($"{builder.Resource.Name}-Config.json", tempConfigFile);
Expand Down
51 changes: 18 additions & 33 deletions src/Aspire.Hosting/ApplicationModel/AspireStore.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
Expand All @@ -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);
Expand Down
45 changes: 15 additions & 30 deletions src/Aspire.Hosting/ApplicationModel/AspireStoreExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,49 +1,34 @@
// 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;

/// <summary>
/// Provides extension methods for <see cref="IDistributedApplicationBuilder"/> to create an <see cref="IAspireStore"/> instance.
/// </summary>
public static class AspireStoreExtensions
{
internal const string AspireStorePathKeyName = "Aspire:Store:Path";

/// <summary>
/// Creates a new App Host store using the provided <paramref name="builder"/>.
/// Gets a deterministic file path that is a copy of the <paramref name="sourceFilename"/>.
/// The resulting file name will depend on the content of the file.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <returns>The <see cref="IAspireStore"/>.</returns>
public static IAspireStore CreateStore(this IDistributedApplicationBuilder builder)
/// <param name="aspireStore">The <see cref="IAspireStore"/> instance.</param>
/// <param name="filenameTemplate">A file name to base the result on.</param>
/// <param name="sourceFilename">An existing file.</param>
/// <returns>A deterministic file path with the same content as <paramref name="sourceFilename"/>.</returns>
/// <exception cref="FileNotFoundException">Thrown when the source file does not exist.</exception>
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<AssemblyMetadataAttribute>();
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"));
}

/// <summary>
/// Gets the metadata value for the specified key from the assembly metadata.
/// </summary>
/// <param name="assemblyMetadata">The assembly metadata.</param>
/// <param name="key">The key to look for.</param>
/// <returns>The metadata value if found; otherwise, null.</returns>
private static string? GetMetadataValue(IEnumerable<AssemblyMetadataAttribute>? 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);
}
}
10 changes: 0 additions & 10 deletions src/Aspire.Hosting/ApplicationModel/IAspireStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,4 @@ public interface IAspireStore
/// <param name="contentStream">A stream containing the content.</param>
/// <returns>A deterministic file path with the same content as the provided stream.</returns>
string GetFileNameWithContent(string filenameTemplate, Stream contentStream);

/// <summary>
/// Gets a deterministic file path that is a copy of the <paramref name="sourceFilename"/>.
/// The resulting file name will depend on the content of the file.
/// </summary>
/// <param name="filenameTemplate">A file name to base the result on.</param>
/// <param name="sourceFilename">An existing file.</param>
/// <returns>A deterministic file path with the same content as <paramref name="sourceFilename"/>.</returns>
/// <exception cref="FileNotFoundException">Thrown when the source file does not exist.</exception>
string GetFileNameWithContent(string filenameTemplate, string sourceFilename);
}
1 change: 1 addition & 0 deletions src/Aspire.Hosting/Aspire.Hosting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<PackageReference Include="Polly.Core" />
<PackageReference Include="JsonPatch.Net" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" />
<PackageReference Include="System.IO.Hashing" />
</ItemGroup>

<ItemGroup>
Expand Down
34 changes: 34 additions & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
["AppHost:Path"] = AppHostPath,
});

var assemblyMetadata = AppHostAssembly?.GetCustomAttributes<AssemblyMetadataAttribute>();
var aspireDir = GetMetadataValue(assemblyMetadata, "AppHostProjectBaseIntermediateOutputPath");

// Only set if there is a valid attribute, otherwise the configuration will come from another source (ENV for instance)
if (!string.IsNullOrEmpty(aspireDir))
{
_innerBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireStore.AspireStorePathKeyName] = aspireDir
});
}

_executionContextOptions = _innerBuilder.Configuration["Publishing:Publisher"] switch
{
"manifest" => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish),
Expand Down Expand Up @@ -208,6 +220,18 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
// from a failure in that case.
o.DefaultWaitBehavior = options.DisableDashboard ? WaitBehavior.StopOnDependencyFailure : WaitBehavior.WaitOnDependencyFailure;
});
_innerBuilder.Services.AddSingleton<IAspireStore, AspireStore>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
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();

Expand Down Expand Up @@ -506,4 +530,14 @@ private static DiagnosticListener LogAppBuilt(DistributedApplication app)

return diagnosticListener;
}

/// <summary>
/// Gets the metadata value for the specified key from the assembly metadata.
/// </summary>
/// <param name="assemblyMetadata">The assembly metadata.</param>
/// <param name="key">The key to look for.</param>
/// <returns>The metadata value if found; otherwise, null.</returns>
private static string? GetMetadataValue(IEnumerable<AssemblyMetadataAttribute>? assemblyMetadata, string key) =>
assemblyMetadata?.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase))?.Value;

}
57 changes: 57 additions & 0 deletions src/Aspire.Hosting/Utils/HashDigestStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 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;

/// <summary>
/// A stream capable of computing the hash digest of raw data while also copying it.
/// </summary>
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 Write(ReadOnlySpan<byte> buffer)
{
_hashAlgorithm.Append(buffer);
_writeStream.Write(buffer);
}

public override void Flush()
{
_writeStream.Flush();
}

internal int GetCurrentUncompressedHash(Span<byte> buffer) => _hashAlgorithm.GetCurrentHash(buffer);

// 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<byte> 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();
}
17 changes: 10 additions & 7 deletions tests/Aspire.Hosting.Tests/AspireStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IAspireStore>();

var path = store.BasePath;

Expand All @@ -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<IAspireStore>();

var filename = "testfile2.txt";
var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Test content"));
Expand Down Expand Up @@ -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<IAspireStore>();
}
}