Skip to content

Commit 4e9896b

Browse files
authored
Fix Azure ServiceBus persistent container support (#7136)
1 parent 2b0dbcf commit 4e9896b

File tree

11 files changed

+535
-130
lines changed

11 files changed

+535
-130
lines changed

playground/AzureServiceBus/ServiceBus.AppHost/Program.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
serviceBus.RunAsEmulator(configure => configure.ConfigureEmulator(document =>
4040
{
4141
document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" };
42-
}));
42+
}).WithLifetime(ContainerLifetime.Persistent));
4343

4444
builder.AddProject<Projects.ServiceBusWorker>("worker")
4545
.WithReference(serviceBus).WaitFor(serviceBus);

src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets

+9
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,15 @@ namespace Projects%3B
242242
</ItemGroup>
243243
</Target>
244244

245+
<Target Name="EmbedAppHostIntermediateOutputPath" BeforeTargets="GetAssemblyAttributes" Condition=" '$(IsAspireHost)' == 'true' ">
246+
<ItemGroup>
247+
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
248+
<_Parameter1>apphostprojectbaseintermediateoutputpath</_Parameter1>
249+
<_Parameter2>$(BaseIntermediateOutputPath)</_Parameter2>
250+
</AssemblyAttribute>
251+
</ItemGroup>
252+
</Target>
253+
245254
<PropertyGroup>
246255
<AspirePublisher Condition="'$(AspirePublisher)' == ''">manifest</AspirePublisher>
247256
<AspireManifestPublishOutputPath Condition="'$(AspireManifestPublishOutputPath)' == ''">$(_AspireIntermediatePath)</AspireManifestPublishOutputPath>

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

+165-122
Large diffs are not rendered by default.

src/Aspire.Hosting/AspireStore.cs

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.Security.Cryptography;
5+
6+
namespace Aspire.Hosting;
7+
8+
internal sealed class AspireStore : IAspireStore
9+
{
10+
private readonly string _basePath;
11+
12+
/// <summary>
13+
/// Initializes a new instance of the <see cref="AspireStore"/> class with the specified base path.
14+
/// </summary>
15+
/// <param name="basePath">The base path for the store.</param>
16+
/// <returns>A new instance of <see cref="AspireStore"/>.</returns>
17+
public AspireStore(string basePath)
18+
{
19+
ArgumentNullException.ThrowIfNull(basePath);
20+
21+
_basePath = basePath;
22+
EnsureDirectory();
23+
}
24+
25+
public string BasePath => _basePath;
26+
27+
public string GetFileNameWithContent(string filenameTemplate, string sourceFilename)
28+
{
29+
ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate);
30+
ArgumentNullException.ThrowIfNullOrWhiteSpace(sourceFilename);
31+
32+
if (!File.Exists(sourceFilename))
33+
{
34+
throw new FileNotFoundException("The source file '{0}' does not exist.", sourceFilename);
35+
}
36+
37+
EnsureDirectory();
38+
39+
// Strip any folder information from the filename.
40+
filenameTemplate = Path.GetFileName(filenameTemplate);
41+
42+
var hashStream = File.OpenRead(sourceFilename);
43+
44+
// Compute the hash of the content.
45+
var hash = SHA256.HashData(hashStream);
46+
47+
hashStream.Dispose();
48+
49+
var name = Path.GetFileNameWithoutExtension(filenameTemplate);
50+
var ext = Path.GetExtension(filenameTemplate);
51+
var finalFilePath = Path.Combine(_basePath, $"{name}.{Convert.ToHexString(hash)[..12].ToLowerInvariant()}{ext}");
52+
53+
if (!File.Exists(finalFilePath))
54+
{
55+
File.Copy(sourceFilename, finalFilePath, overwrite: true);
56+
}
57+
58+
return finalFilePath;
59+
}
60+
61+
public string GetFileNameWithContent(string filenameTemplate, Stream contentStream)
62+
{
63+
ArgumentNullException.ThrowIfNullOrWhiteSpace(filenameTemplate);
64+
ArgumentNullException.ThrowIfNull(contentStream);
65+
66+
// Create a temporary file to write the content to.
67+
var tempFileName = Path.GetTempFileName();
68+
69+
// Write the content to the temporary file.
70+
using (var fileStream = File.OpenWrite(tempFileName))
71+
{
72+
contentStream.CopyTo(fileStream);
73+
}
74+
75+
var finalFilePath = GetFileNameWithContent(filenameTemplate, tempFileName);
76+
77+
try
78+
{
79+
File.Delete(tempFileName);
80+
}
81+
catch
82+
{
83+
}
84+
85+
return finalFilePath;
86+
}
87+
88+
/// <summary>
89+
/// Ensures that the directory for the store exists.
90+
/// </summary>
91+
private void EnsureDirectory()
92+
{
93+
if (!string.IsNullOrEmpty(_basePath))
94+
{
95+
Directory.CreateDirectory(_basePath);
96+
}
97+
}
98+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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.Reflection;
5+
6+
namespace Aspire.Hosting;
7+
8+
/// <summary>
9+
/// Provides extension methods for <see cref="IDistributedApplicationBuilder"/> to create an <see cref="IAspireStore"/> instance.
10+
/// </summary>
11+
public static class AspireStoreExtensions
12+
{
13+
internal const string AspireStorePathKeyName = "Aspire:Store:Path";
14+
15+
/// <summary>
16+
/// Creates a new App Host store using the provided <paramref name="builder"/>.
17+
/// </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)
21+
{
22+
ArgumentNullException.ThrowIfNull(builder);
23+
24+
var aspireDir = builder.Configuration[AspireStorePathKeyName];
25+
26+
if (string.IsNullOrWhiteSpace(aspireDir))
27+
{
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+
}
35+
}
36+
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;
48+
49+
}

src/Aspire.Hosting/IAspireStore.cs

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
namespace Aspire.Hosting;
5+
6+
/// <summary>
7+
/// Represents a store for managing files in the Aspire hosting environment that can be reused across runs.
8+
/// </summary>
9+
/// <remarks>
10+
/// The store is created in the ./obj folder of the Application Host.
11+
/// If the ASPIRE_STORE_DIR environment variable is set this will be used instead.
12+
///
13+
/// The store is specific to a <see cref="IDistributedApplicationBuilder"/> instance such that each application can't
14+
/// conflict with others. A <em>.aspire</em> prefix is also used to ensure that the folder can be delete without impacting
15+
/// unrelated files.
16+
/// </remarks>
17+
public interface IAspireStore
18+
{
19+
/// <summary>
20+
/// Gets the base path of this store.
21+
/// </summary>
22+
string BasePath { get; }
23+
24+
/// <summary>
25+
/// Gets a deterministic file path that is a copy of the content from the provided stream.
26+
/// The resulting file name will depend on the content of the stream.
27+
/// </summary>
28+
/// <param name="filenameTemplate">A file name to base the result on.</param>
29+
/// <param name="contentStream">A stream containing the content.</param>
30+
/// <returns>A deterministic file path with the same content as the provided stream.</returns>
31+
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);
42+
}

src/Shared/SecretsStore.cs

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
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.Runtime.InteropServices;
54
using System.Text;
65
using System.Text.Json.Nodes;
76
using Microsoft.Extensions.Configuration;

tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs

+48-5
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ public async Task VerifyWaitForOnServiceBusEmulatorBlocksDependentResources()
188188
{
189189
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
190190
using var builder = TestDistributedApplicationBuilder.Create(output);
191+
191192

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

233234
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(output);
235+
234236
var serviceBus = builder.AddAzureServiceBus("servicebusns")
235237
.RunAsEmulator()
236238
.WithQueue("queue123");
@@ -267,6 +269,7 @@ public async Task VerifyAzureServiceBusEmulatorResource()
267269
public void AddAzureServiceBusWithEmulatorGetsExpectedPort(int? port = null)
268270
{
269271
using var builder = TestDistributedApplicationBuilder.Create();
272+
270273
var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder =>
271274
{
272275
builder.WithHostPort(port);
@@ -601,10 +604,16 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz
601604
using var builder = TestDistributedApplicationBuilder.Create();
602605

603606
var serviceBus = builder.AddAzureServiceBus("servicebusns")
604-
.RunAsEmulator(configure => configure.ConfigureEmulator(document =>
605-
{
606-
document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" };
607-
}));
607+
.RunAsEmulator(configure => configure
608+
.ConfigureEmulator(document =>
609+
{
610+
document["UserConfig"]!["Logging"] = new JsonObject { ["Type"] = "Console" };
611+
})
612+
.ConfigureEmulator(document =>
613+
{
614+
document["Custom"] = JsonValue.Create(42);
615+
})
616+
);
608617

609618
using var app = builder.Build();
610619
await app.StartAsync();
@@ -627,7 +636,8 @@ public async Task AzureServiceBusEmulatorResourceGeneratesConfigJsonWithCustomiz
627636
"Logging": {
628637
"Type": "Console"
629638
}
630-
}
639+
},
640+
"Custom": 42
631641
}
632642
""", configJsonContent);
633643

@@ -692,4 +702,37 @@ public async Task AzureServiceBusEmulator_WithConfigurationFile()
692702
{
693703
}
694704
}
705+
706+
[Theory]
707+
[InlineData(true)]
708+
[InlineData(false)]
709+
public void AddAzureServiceBusWithEmulator_SetsSqlLifetime(bool isPersistent)
710+
{
711+
using var builder = TestDistributedApplicationBuilder.Create();
712+
var lifetime = isPersistent ? ContainerLifetime.Persistent : ContainerLifetime.Session;
713+
714+
var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator(configureContainer: builder =>
715+
{
716+
builder.WithLifetime(lifetime);
717+
});
718+
719+
var sql = builder.Resources.FirstOrDefault(x => x.Name == "sb-sqledge");
720+
721+
Assert.NotNull(sql);
722+
723+
serviceBus.Resource.TryGetLastAnnotation<ContainerLifetimeAnnotation>(out var sbLifetimeAnnotation);
724+
sql.TryGetLastAnnotation<ContainerLifetimeAnnotation>(out var sqlLifetimeAnnotation);
725+
726+
Assert.Equal(lifetime, sbLifetimeAnnotation?.Lifetime);
727+
Assert.Equal(lifetime, sqlLifetimeAnnotation?.Lifetime);
728+
}
729+
730+
[Fact]
731+
public void RunAsEmulator_CalledTwice_Throws()
732+
{
733+
using var builder = TestDistributedApplicationBuilder.Create();
734+
var serviceBus = builder.AddAzureServiceBus("sb").RunAsEmulator();
735+
736+
Assert.Throws<InvalidOperationException>(() => serviceBus.RunAsEmulator());
737+
}
695738
}

0 commit comments

Comments
 (0)