-
Notifications
You must be signed in to change notification settings - Fork 481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for SignalR emulator #6793
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,11 +11,13 @@ | |
<PropertyGroup> | ||
<MinCodeCoverage>96</MinCodeCoverage> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="..\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj" /> | ||
<PackageReference Include="Azure.Provisioning" /> | ||
<PackageReference Include="Azure.Provisioning.SignalR" /> | ||
<PackageReference Include="Microsoft.Azure.SignalR.Management" /> | ||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you need the signalr client reference? Looks like it's only used in a test, so shouldn't the reference be on the test project? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you're right, let me move it to test project |
||
</ItemGroup> | ||
|
||
</Project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using Aspire.Hosting.ApplicationModel; | ||
|
||
namespace Aspire.Hosting.Azure; | ||
|
||
/// <summary> | ||
/// Wraps an <see cref="AzureSignalRResource" /> in a type that exposes container extension methods. | ||
/// </summary> | ||
/// <param name="innerResource">The inner resource used to store annotations.</param> | ||
public class AzureSignalREmulatorResource(AzureSignalRResource innerResource) : ContainerResource(innerResource.Name), IResource | ||
{ | ||
internal const string EmulatorConfigJsonPath = "/emulator/settings.json"; | ||
|
||
private readonly AzureSignalRResource _innerResource = innerResource; | ||
|
||
/// <inheritdoc/> | ||
public override string Name => _innerResource.Name; | ||
|
||
/// <inheritdoc/> | ||
public override ResourceAnnotationCollection Annotations => _innerResource.Annotations; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,8 +3,12 @@ | |
|
||
using Aspire.Hosting.ApplicationModel; | ||
using Aspire.Hosting.Azure; | ||
using Aspire.Hosting.Azure.SignalR; | ||
using Azure.Provisioning; | ||
using Azure.Provisioning.SignalR; | ||
using Microsoft.Azure.SignalR.Management; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Microsoft.Extensions.Diagnostics.HealthChecks; | ||
|
||
namespace Aspire.Hosting; | ||
|
||
|
@@ -59,4 +63,61 @@ public static IResourceBuilder<AzureSignalRResource> AddAzureSignalR(this IDistr | |
.WithParameter(AzureBicepResource.KnownParameters.PrincipalType) | ||
.WithManifestPublishingCallback(resource.WriteToManifest); | ||
} | ||
|
||
/// <summary> | ||
/// Configures an Azure SignalR resource to be emulated. This resource requires an <see cref="AzureSignalRResource"/> to be added to the application model. | ||
/// </summary> | ||
/// <remarks> | ||
/// This version of the package defaults to the <inheritdoc cref="SignalREmulatorContainerImageTags.Tag"/> tag of the <inheritdoc cref="SignalREmulatorContainerImageTags.Registry"/>/<inheritdoc cref="SignalREmulatorContainerImageTags.Image"/> container image. | ||
/// </remarks> | ||
/// <param name="builder">The Azure storage resource builder.</param> | ||
/// <param name="configureContainer">Callback that exposes underlying container used for emulation to allow for customization.</param> | ||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns> | ||
public static IResourceBuilder<AzureSignalRResource> RunAsEmulator(this IResourceBuilder<AzureSignalRResource> builder, Action<IResourceBuilder<AzureSignalREmulatorResource>>? configureContainer = null) | ||
{ | ||
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) | ||
{ | ||
return builder; | ||
} | ||
|
||
string? connectionString = null; | ||
builder | ||
.WithEndpoint(name: "emulator", targetPort: 8888, scheme: "http") | ||
.WithAnnotation(new ContainerImageAnnotation | ||
{ | ||
Registry = SignalREmulatorContainerImageTags.Registry, | ||
Image = SignalREmulatorContainerImageTags.Image, | ||
Tag = SignalREmulatorContainerImageTags.Tag | ||
}); | ||
|
||
builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(builder.Resource, async (@event, ct) => | ||
{ | ||
connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false) | ||
?? throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); | ||
}); | ||
var healthCheckKey = $"{builder.Resource.Name}_check"; | ||
var healthCheckRegistration = new HealthCheckRegistration( | ||
healthCheckKey, | ||
sp => { | ||
// Use SignalR Management SDK to init a client for health test | ||
var client = new ServiceManagerBuilder() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does a simple http call work? |
||
.WithOptions(option => { | ||
option.ConnectionString = connectionString ?? throw new InvalidOperationException("Connection string is unavailable"); | ||
option.ServiceTransportType = ServiceTransportType.Transient; | ||
}) | ||
.BuildServiceManager(); | ||
return new AzureSignalRHealthCheck(client); | ||
}, | ||
failureStatus: default, | ||
tags: default | ||
); | ||
builder.ApplicationBuilder.Services.AddHealthChecks().Add(healthCheckRegistration); | ||
if (configureContainer != null) | ||
{ | ||
var surrogate = new AzureSignalREmulatorResource(builder.Resource); | ||
var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(surrogate); | ||
configureContainer(surrogateBuilder); | ||
} | ||
return builder.WithHealthCheck(healthCheckKey); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using Microsoft.Azure.SignalR.Management; | ||
using Microsoft.Extensions.Diagnostics.HealthChecks; | ||
|
||
namespace Aspire.Hosting.Azure.SignalR; | ||
internal sealed class AzureSignalRHealthCheck : IHealthCheck | ||
{ | ||
private readonly ServiceManager _signalRClient; | ||
|
||
public AzureSignalRHealthCheck(ServiceManager signalRClient) | ||
{ | ||
ArgumentNullException.ThrowIfNull(signalRClient ,nameof(signalRClient)); | ||
_signalRClient = signalRClient; | ||
} | ||
|
||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) | ||
{ | ||
try | ||
{ | ||
await _signalRClient.IsServiceHealthy(cancellationToken).ConfigureAwait(false); | ||
|
||
return HealthCheckResult.Healthy(); | ||
} | ||
catch (Exception ex) | ||
{ | ||
return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,8 +12,16 @@ namespace Aspire.Hosting.ApplicationModel; | |
/// <param name="configureInfrastructure">Callback to configure the Azure resources.</param> | ||
public class AzureSignalRResource(string name, Action<AzureResourceInfrastructure> configureInfrastructure) : | ||
AzureProvisioningResource(name, configureInfrastructure), | ||
IResourceWithConnectionString | ||
IResourceWithConnectionString, | ||
IResourceWithEndpoints | ||
{ | ||
|
||
internal EndpointReference EmulatorEndpoint => new(this, "emulator"); | ||
/// <summary> | ||
/// Gets a value indicating whether the Azure SignalR resource is running in the local emulator. | ||
/// </summary> | ||
public bool IsEmulator => this.IsContainer(); | ||
|
||
/// <summary> | ||
/// Gets the "connectionString" output reference from the bicep template for Azure SignalR. | ||
/// </summary> | ||
|
@@ -23,5 +31,7 @@ public class AzureSignalRResource(string name, Action<AzureResourceInfrastructur | |
/// Gets the connection string template for the manifest for Azure SignalR. | ||
/// </summary> | ||
public ReferenceExpression ConnectionStringExpression => | ||
ReferenceExpression.Create($"Endpoint=https://{HostName};AuthType=azure"); | ||
IsEmulator | ||
? ReferenceExpression.Create($"Endpoint={EmulatorEndpoint.Property(EndpointProperty.Scheme)}://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0;") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we update the emulator to not dependent on "AccessKey" so that we don't need to have the AccessKey in code although it is fake There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Revert the comment. Emulator could be working in an isolated environment, so Microsoft Entra ID should not be a dependency when using Emulator. AccessKey is a best choice for existing SDKs to work seamlessly. |
||
: ReferenceExpression.Create($"Endpoint=https://{HostName};AuthType=azure"); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
// 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.Azure.SignalR; | ||
internal static class SignalREmulatorContainerImageTags | ||
{ | ||
/// <summary>mcr.microsoft.com</summary> | ||
public const string Registry = "mcr.microsoft.com"; | ||
|
||
/// <summary>signalr/signalr-emulator</summary> | ||
public const string Image = "signalr/signalr-emulator"; | ||
|
||
/// <summary>latest</summary> | ||
public const string Tag = "latest"; // latest is the only arch-agnostic tag | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using Aspire.Components.Common.Tests; | ||
using Aspire.Hosting.ApplicationModel; | ||
using Aspire.Hosting.Utils; | ||
using Microsoft.AspNetCore.SignalR; | ||
using Microsoft.AspNetCore.SignalR.Client; | ||
using Microsoft.Azure.SignalR.Management; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Microsoft.Extensions.Diagnostics.HealthChecks; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
|
||
namespace Aspire.Hosting.Azure.Tests; | ||
public class AzureSignalREmulatorFunctionalTest(ITestOutputHelper testOutputHelper) | ||
{ | ||
|
||
[Fact] | ||
[RequiresDocker] | ||
public async Task VerifyWaitForOnAzureSignalREmulatorBlocksDependentResources() | ||
{ | ||
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); | ||
using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); | ||
|
||
var healthCheckTcs = new TaskCompletionSource<HealthCheckResult>(); | ||
builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => | ||
{ | ||
return healthCheckTcs.Task; | ||
}); | ||
|
||
var signalR = builder.AddAzureSignalR("resource") | ||
.RunAsEmulator() | ||
.WithHealthCheck("blocking_check"); | ||
|
||
var dependentResource = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") | ||
.WaitFor(signalR); | ||
using var app = builder.Build(); | ||
|
||
var pendingStart = app.StartAsync(cts.Token); | ||
var rns = app.Services.GetRequiredService<ResourceNotificationService>(); | ||
await rns.WaitForResourceAsync(signalR.Resource.Name, KnownResourceStates.Running, cts.Token); | ||
await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); | ||
|
||
healthCheckTcs.SetResult(HealthCheckResult.Healthy()); | ||
|
||
await rns.WaitForResourceHealthyAsync(signalR.Resource.Name, cts.Token); | ||
|
||
await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); | ||
|
||
await pendingStart; | ||
|
||
await app.StopAsync(); | ||
} | ||
|
||
[Fact] | ||
[RequiresDocker] | ||
public async Task VerifyAzureSignalREmulatorResource() | ||
{ | ||
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); | ||
var signalR = builder | ||
.AddAzureSignalR("signalR") | ||
.RunAsEmulator(); | ||
|
||
using var app = builder.Build(); | ||
await app.StartAsync(); | ||
|
||
var connectionString = await signalR.Resource.ConnectionStringExpression.GetValueAsync(default); | ||
var serviceManager = new ServiceManagerBuilder() | ||
.WithOptions(option => { option.ConnectionString = connectionString; }) | ||
.BuildServiceManager(); | ||
Assert.True(await serviceManager.IsServiceHealthy(default)); | ||
|
||
// Get negotiate URL to init a signalR connection | ||
var serviceHubContext = await serviceManager.CreateHubContextAsync("hub1", default); | ||
var negotiationResponse = await serviceHubContext.NegotiateAsync(new() { UserId = "testId" } ); | ||
var connection = new HubConnectionBuilder().WithUrl(negotiationResponse.Url ?? "", option => | ||
{ | ||
option.AccessTokenProvider = () => Task.FromResult(negotiationResponse.AccessToken); | ||
}).Build(); | ||
var messageTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
connection.On<string>("broadcast", message => | ||
{ | ||
messageTcs.TrySetResult(message); | ||
}); | ||
await connection.StartAsync(); | ||
|
||
// Broadcast message to all clients | ||
var sentMessage = "Hello, World!"; | ||
await serviceHubContext.Clients.All.SendAsync("broadcast", sentMessage); | ||
|
||
// Verify that received message is the same as sent message | ||
Assert.Equal(sentMessage, await messageTcs.Task); | ||
await app.StopAsync(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a little bit heavy dependency, is it possible to not dependent on it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the convention for other services is to use the Service SDK to init a client for healthcheck. In my opinion, it looks more clean that way as well. Also the tests use this SDK to get negotiate URL as well
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Microsoft.Azure.SignalR.Management is a little bit different story, and it references Microsoft.Azure.SignalR which references aspnetcore SignalR. It sounds too much for a project doing the service provisioning