Skip to content

Commit

Permalink
Fix Azure ServiceBus persistent container support (#7136)
Browse files Browse the repository at this point in the history
  • Loading branch information
sebastienros authored Feb 5, 2025
1 parent 2b0dbcf commit 4e9896b
Show file tree
Hide file tree
Showing 11 changed files with 535 additions and 130 deletions.
2 changes: 1 addition & 1 deletion playground/AzureServiceBus/ServiceBus.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
serviceBus.RunAsEmulator(configure => configure.ConfigureEmulator(document =>
{
document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" };
}));
}).WithLifetime(ContainerLifetime.Persistent));

builder.AddProject<Projects.ServiceBusWorker>("worker")
.WithReference(serviceBus).WaitFor(serviceBus);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@ namespace Projects%3B
</ItemGroup>
</Target>

<Target Name="EmbedAppHostIntermediateOutputPath" BeforeTargets="GetAssemblyAttributes" Condition=" '$(IsAspireHost)' == 'true' ">
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>apphostprojectbaseintermediateoutputpath</_Parameter1>
<_Parameter2>$(BaseIntermediateOutputPath)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Target>

<PropertyGroup>
<AspirePublisher Condition="'$(AspirePublisher)' == ''">manifest</AspirePublisher>
<AspireManifestPublishOutputPath Condition="'$(AspireManifestPublishOutputPath)' == ''">$(_AspireIntermediatePath)</AspireManifestPublishOutputPath>
Expand Down
287 changes: 165 additions & 122 deletions src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions src/Aspire.Hosting/AspireStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Cryptography;

namespace Aspire.Hosting;

internal sealed class AspireStore : IAspireStore
{
private readonly string _basePath;

/// <summary>
/// Initializes a new instance of the <see cref="AspireStore"/> class with the specified base path.
/// </summary>
/// <param name="basePath">The base path for the store.</param>
/// <returns>A new instance of <see cref="AspireStore"/>.</returns>
public AspireStore(string basePath)
{
ArgumentNullException.ThrowIfNull(basePath);

_basePath = basePath;
EnsureDirectory();
}

public string BasePath => _basePath;

public string GetFileNameWithContent(string filenameTemplate, string sourceFilename)
{
ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate);
ArgumentNullException.ThrowIfNullOrWhiteSpace(sourceFilename);

if (!File.Exists(sourceFilename))
{
throw new FileNotFoundException("The source file '{0}' does not exist.", sourceFilename);
}

EnsureDirectory();

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

var hashStream = File.OpenRead(sourceFilename);

// Compute the hash of the content.
var hash = SHA256.HashData(hashStream);

hashStream.Dispose();

var name = Path.GetFileNameWithoutExtension(filenameTemplate);
var ext = Path.GetExtension(filenameTemplate);
var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}");

if (!File.Exists(finalFilePath))
{
File.Copy(sourceFilename, finalFilePath, overwrite: true);
}

return finalFilePath;
}

public string GetFileNameWithContent(string filenameTemplate, Stream contentStream)
{
ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate);
ArgumentNullException.ThrowIfNull(contentStream);

// Create a temporary file to write the content to.
var tempFileName = Path.GetTempFileName();

// Write the content to the temporary file.
using (var fileStream = File.OpenWrite(tempFileName))
{
contentStream.CopyTo(fileStream);
}

var finalFilePath = GetFileNameWithContent(filenameTemplate, tempFileName);

try
{
File.Delete(tempFileName);
}
catch
{
}

return finalFilePath;
}

/// <summary>
/// Ensures that the directory for the store exists.
/// </summary>
private void EnsureDirectory()
{
if (!string.IsNullOrEmpty(_basePath))
{
Directory.CreateDirectory(_basePath);
}
}
}
49 changes: 49 additions & 0 deletions src/Aspire.Hosting/AspireStoreExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <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"/>.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <returns>The <see cref="IAspireStore"/>.</returns>
public static IAspireStore CreateStore(this IDistributedApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);

var aspireDir = builder.Configuration[AspireStorePathKeyName];

if (string.IsNullOrWhiteSpace(aspireDir))
{
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.");
}
}

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;

}
42 changes: 42 additions & 0 deletions src/Aspire.Hosting/IAspireStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting;

/// <summary>
/// Represents a store for managing files in the Aspire hosting environment that can be reused across runs.
/// </summary>
/// <remarks>
/// 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 <see cref="IDistributedApplicationBuilder"/> instance such that each application can't
/// conflict with others. A <em>.aspire</em> prefix is also used to ensure that the folder can be delete without impacting
/// unrelated files.
/// </remarks>
public interface IAspireStore
{
/// <summary>
/// Gets the base path of this store.
/// </summary>
string BasePath { get; }

/// <summary>
/// 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.
/// </summary>
/// <param name="filenameTemplate">A file name to base the result on.</param>
/// <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: 0 additions & 1 deletion src/Shared/SecretsStore.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ public async Task VerifyWaitForOnServiceBusEmulatorBlocksDependentResources()
{
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
using var builder = TestDistributedApplicationBuilder.Create(output);


var healthCheckTcs = new TaskCompletionSource<HealthCheckResult>();
builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () =>
Expand Down Expand Up @@ -231,6 +232,7 @@ public async Task VerifyAzureServiceBusEmulatorResource()
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));

using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(output);

var serviceBus = builder.AddAzureServiceBus("servicebusns")
.RunAsEmulator()
.WithQueue("queue123");
Expand Down Expand Up @@ -267,6 +269,7 @@ public async Task VerifyAzureServiceBusEmulatorResource()
public void AddAzureServiceBusWithEmulatorGetsExpectedPort(int? port = null)
{
using var builder = TestDistributedApplicationBuilder.Create();

var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder =>
{
builder.WithHostPort(port);
Expand Down Expand Up @@ -601,10 +604,16 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz
using var builder = TestDistributedApplicationBuilder.Create();

var serviceBus = builder.AddAzureServiceBus("servicebusns")
.RunAsEmulator(configure => configure.ConfigureEmulator(document =>
{
document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" };
}));
.RunAsEmulator(configure => configure
.ConfigureEmulator(document =>
{
document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" };
})
.ConfigureEmulator(document =>
{
document["Custom"] = JsonValue.Create(42);
})
);

using var app = builder.Build();
await app.StartAsync();
Expand All @@ -627,7 +636,8 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz
"Logging": {
"Type": "Console"
}
}
},
"Custom": 42
}
""", configJsonContent);

Expand Down Expand Up @@ -692,4 +702,37 @@ public async Task AzureServiceBusEmulator_WithConfigurationFile()
{
}
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent)
{
using var builder = TestDistributedApplicationBuilder.Create();
var lifetime = isPersistent ? ContainerLifetime.Persistent : ContainerLifetime.Session;

var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder =>
{
builder.WithLifetime(lifetime);
});

var sql = builder.Resources.FirstOrDefault(x => x.Name == "sb-sqledge");

Assert.NotNull(sql);

serviceBus.Resource.TryGetLastAnnotation<ContainerLifetimeAnnotation>(out var sbLifetimeAnnotation);
sql.TryGetLastAnnotation<ContainerLifetimeAnnotation>(out var sqlLifetimeAnnotation);

Assert.Equal(lifetime, sbLifetimeAnnotation?.Lifetime);
Assert.Equal(lifetime, sqlLifetimeAnnotation?.Lifetime);
}

[Fact]
public void RunAsEmulator_CalledTwice_Throws()
{
using var builder = TestDistributedApplicationBuilder.Create();
var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator();

Assert.Throws<InvalidOperationException>(() => serviceBus.RunAsEmulator());
}
}
Loading

0 comments on commit 4e9896b

Please sign in to comment.