Skip to content
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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
<PackageVersion Include="Azure.Security.KeyVault.Secrets" Version="4.7.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.23.0" />
<PackageVersion Include="Azure.Storage.Queues" Version="12.21.0" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageVersion Include="Microsoft.Azure.AppConfiguration.AspNetCore" Version="8.0.0" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.45.0" />
<PackageVersion Include="Microsoft.Azure.SignalR" Version="1.28.0" />
<PackageVersion Include="Microsoft.Azure.SignalR.Management" Version="1.28.0" />
<PackageVersion Include="Microsoft.Extensions.Azure" Version="1.8.0" />
<!-- Azure Management SDK for .NET dependencies -->
<PackageVersion Include="Azure.Provisioning" Version="$(AzureProvisiongVersion)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Copy link
Contributor

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?

Copy link
Author

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

Copy link
Contributor

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

<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, let me move it to test project

</ItemGroup>

</Project>
23 changes: 23 additions & 0 deletions src/Aspire.Hosting.Azure.SignalR/AzureSignalREmulatorResource.cs
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;
}
61 changes: 61 additions & 0 deletions src/Aspire.Hosting.Azure.SignalR/AzureSignalRExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
}
}
31 changes: 31 additions & 0 deletions src/Aspire.Hosting.Azure.SignalR/AzureSignalRHealthCheck.cs
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);
}
}
}
14 changes: 12 additions & 2 deletions src/Aspire.Hosting.Azure.SignalR/AzureSignalRResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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;")
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The 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");
}
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.Azure.SignalR/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@
*REMOVED*static Aspire.Hosting.AzureSignalRExtensions.AddAzureSignalR(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Action<Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.AzureSignalRResource!>!, Aspire.Hosting.ResourceModuleConstruct!, Azure.Provisioning.SignalR.SignalRService!>? configureResource) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.AzureSignalRResource!>!
*REMOVED*Aspire.Hosting.ApplicationModel.AzureSignalRResource.AzureSignalRResource(string! name, System.Action<Aspire.Hosting.ResourceModuleConstruct!>! configureConstruct) -> void
Aspire.Hosting.ApplicationModel.AzureSignalRResource.AzureSignalRResource(string! name, System.Action<Aspire.Hosting.Azure.AzureResourceInfrastructure!>! configureInfrastructure) -> void
Aspire.Hosting.ApplicationModel.AzureSignalRResource.IsEmulator.get -> bool
Aspire.Hosting.Azure.AzureSignalREmulatorResource
Aspire.Hosting.Azure.AzureSignalREmulatorResource.AzureSignalREmulatorResource(Aspire.Hosting.ApplicationModel.AzureSignalRResource! innerResource) -> void
override Aspire.Hosting.Azure.AzureSignalREmulatorResource.Annotations.get -> Aspire.Hosting.ApplicationModel.ResourceAnnotationCollection!
override Aspire.Hosting.Azure.AzureSignalREmulatorResource.Name.get -> string!
static Aspire.Hosting.AzureSignalRExtensions.RunAsEmulator(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.AzureSignalRResource!>! builder, System.Action<Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Azure.AzureSignalREmulatorResource!>!>? configureContainer = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.AzureSignalRResource!>!
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();
}
}
Loading