diff --git a/playground/CustomResources/CustomResources.AppHost/TalkingClockResource.cs b/playground/CustomResources/CustomResources.AppHost/TalkingClockResource.cs index 9eb1894c6fc..6bb288d5139 100644 --- a/playground/CustomResources/CustomResources.AppHost/TalkingClockResource.cs +++ b/playground/CustomResources/CustomResources.AppHost/TalkingClockResource.cs @@ -29,7 +29,28 @@ public static IResourceBuilder AddTalkingClock( var tockHandResource = new ClockHandResource(name + "-tock-hand"); var clockResource = new TalkingClockResource(name, tickHandResource, tockHandResource); - builder.Eventing.Subscribe(clockResource, static async (@event, token) => + // Add the resource instance to the Aspire application builder and configure it using fluent APIs. + var clockBuilder = builder.AddResource(clockResource) + // Use Aspire's ExcludeFromManifest to prevent this resource from being included in deployment manifests. + .ExcludeFromManifest() + // Set a URL for the resource, which will be displayed in the Aspire dashboard. + .WithUrl("https://www.speaking-clock.com/", "Speaking Clock") + // Use Aspire's WithInitialState to set an initial state snapshot for the resource. + // This provides initial metadata visible in the Aspire dashboard. + .WithInitialState(new CustomResourceSnapshot // Aspire type for custom resource state. + { + ResourceType = "TalkingClock", // A string identifying the type of resource for Aspire, this shows in the dashboard. + CreationTimeStamp = DateTime.UtcNow, + State = KnownResourceStates.NotStarted, // Use an Aspire well-known state. + // Add custom properties displayed in the Aspire dashboard's resource details. + Properties = + [ + // Use Aspire's known property key for source information. + new(CustomResourceKnownProperties.Source, "Talking Clock") + ] + }); + + clockBuilder.OnInitializeResource(static async (resource, @event, token) => { // This event is published when the resource is initialized. // You add custom logic here to establish the lifecycle for your custom resource. @@ -37,7 +58,6 @@ public static IResourceBuilder AddTalkingClock( var log = @event.Logger; // Get the logger for this resource instance. var eventing = @event.Eventing; // Get the eventing service for publishing events. var notification = @event.Notifications; // Get the notification service for state updates. - var resource = (TalkingClockResource)@event.Resource; // Get the resource instance. var services = @event.Services; // Get the service provider for dependency injection. // Publish an Aspire event indicating that this resource is about to start. @@ -97,27 +117,6 @@ await notification.PublishUpdateAsync(resource.TockHand, } }); - // Add the resource instance to the Aspire application builder and configure it using fluent APIs. - var clockBuilder = builder.AddResource(clockResource) - // Use Aspire's ExcludeFromManifest to prevent this resource from being included in deployment manifests. - .ExcludeFromManifest() - // Set a URL for the resource, which will be displayed in the Aspire dashboard. - .WithUrl("https://www.speaking-clock.com/", "Speaking Clock") - // Use Aspire's WithInitialState to set an initial state snapshot for the resource. - // This provides initial metadata visible in the Aspire dashboard. - .WithInitialState(new CustomResourceSnapshot // Aspire type for custom resource state. - { - ResourceType = "TalkingClock", // A string identifying the type of resource for Aspire, this shows in the dashboard. - CreationTimeStamp = DateTime.UtcNow, - State = KnownResourceStates.NotStarted, // Use an Aspire well-known state. - // Add custom properties displayed in the Aspire dashboard's resource details. - Properties = - [ - // Use Aspire's known property key for source information. - new(CustomResourceKnownProperties.Source, "Talking Clock") - ] - }); - AddHandResource(tickHandResource); AddHandResource(tockHandResource); diff --git a/playground/mongo/Mongo.AppHost/Program.cs b/playground/mongo/Mongo.AppHost/Program.cs index a73b7f234fd..cd35b64aabd 100644 --- a/playground/mongo/Mongo.AppHost/Program.cs +++ b/playground/mongo/Mongo.AppHost/Program.cs @@ -9,27 +9,26 @@ var db = builder.AddMongoDB("mongo") .WithMongoExpress(c => c.WithHostPort(3022)) - .AddDatabase("db"); - -builder.Eventing.Subscribe(db.Resource, async (@event, ct) => -{ - // Artificial delay to demonstrate the waiting - await Task.Delay(TimeSpan.FromSeconds(10), ct); - - // Seed the database with some data - var cs = await db.Resource.ConnectionStringExpression.GetValueAsync(ct); - using var client = new MongoClient(cs); - - const string collectionName = "entries"; - - var myDb = client.GetDatabase("db"); - await myDb.CreateCollectionAsync(collectionName, cancellationToken: ct); - - for (int i = 0; i < 10; i++) - { - await myDb.GetCollection(collectionName).InsertOneAsync(new Entry(), cancellationToken: ct); - } -}); + .AddDatabase("db") + .OnResourceReady(async (db, @event, ct) =>{ + // Artificial delay to demonstrate the waiting + await Task.Delay(TimeSpan.FromSeconds(10), ct); + + // Seed the database with some data + //var cs = await db.Resource.ConnectionStringExpression.GetValueAsync(ct); + var cs = await db.ConnectionStringExpression.GetValueAsync(ct); + using var client = new MongoClient(cs); + + const string collectionName = "entries"; + + var myDb = client.GetDatabase("db"); + await myDb.CreateCollectionAsync(collectionName, cancellationToken: ct); + + for (int i = 0; i < 10; i++) + { + await myDb.GetCollection(collectionName).InsertOneAsync(new Entry(), cancellationToken: ct); + } + }); builder.AddProject("api") .WithExternalHttpEndpoints() diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AzureAIFoundryExtensions.cs b/src/Aspire.Hosting.Azure.AIFoundry/AzureAIFoundryExtensions.cs index 884e40b6373..96f2c18e422 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AzureAIFoundryExtensions.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AzureAIFoundryExtensions.cs @@ -132,10 +132,9 @@ public static IResourceBuilder RunAsFoundryLocal(this IR private static IResourceBuilder WithInitializer(this IResourceBuilder builder) { - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, (@event, ct) + return builder.OnInitializeResource((resource, @event, ct) => Task.Run(async () => { - var resource = (AzureAIFoundryResource)@event.Resource; var rns = @event.Services.GetRequiredService(); var manager = @event.Services.GetRequiredService(); var logger = @event.Services.GetRequiredService().GetLogger(resource); @@ -176,8 +175,6 @@ await rns.PublishUpdateAsync(resource, state => state with } }, ct)); - - return builder; } /// @@ -187,9 +184,7 @@ internal static IResourceBuilder AsLocalDeploy { ArgumentNullException.ThrowIfNull(deployment, nameof(deployment)); - var foundryResource = builder.Resource.Parent; - - builder.ApplicationBuilder.Eventing.Subscribe(foundryResource, (@event, ct) => + builder.OnResourceReady((foundryResource, @event, ct) => { var rns = @event.Services.GetRequiredService(); var loggerService = @event.Services.GetRequiredService(); diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs index 782961c8b38..85c0dbdc309 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs @@ -88,10 +88,9 @@ private static IResourceBuilder RunAsEmulator(this IResou }); CosmosClient? cosmosClient = null; - - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => + builder.OnConnectionStringAvailable(async (cosmosDb, @event, ct) => { - var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + var connectionString = await cosmosDb.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); if (connectionString == null) { @@ -99,9 +98,8 @@ private static IResourceBuilder RunAsEmulator(this IResou } cosmosClient = CreateCosmosClient(connectionString); - }); - - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => + }) + .OnResourceReady(async (cosmosDb, @event, ct) => { if (cosmosClient is null) { @@ -110,7 +108,7 @@ private static IResourceBuilder RunAsEmulator(this IResou await cosmosClient.ReadAccountAsync().WaitAsync(ct).ConfigureAwait(false); - foreach (var database in builder.Resource.Databases) + foreach (var database in cosmosDb.Databases) { var db = (await cosmosClient.CreateDatabaseIfNotExistsAsync(database.DatabaseName, cancellationToken: ct).ConfigureAwait(false)).Database; diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 7437b568f03..70ac922c4dc 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -132,32 +132,32 @@ public static IResourceBuilder RunAsEmulator(this IResourc }); BlobServiceClient? blobServiceClient = null; - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => - { - // The BlobServiceClient is created before the health check is run. - // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so - // we use BeforeResourceStartedEvent - - var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null."); - blobServiceClient = CreateBlobServiceClient(connectionString); - }); - - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => - { - // The ResourceReadyEvent of a resource is triggered after its health check is healthy. - // This means we can safely use this event to create the blob containers. - - if (blobServiceClient is null) + builder + .OnBeforeResourceStarted(async (storage, @event, ct) => { - throw new InvalidOperationException("BlobServiceClient is not initialized."); - } - - foreach (var container in builder.Resource.BlobContainers) + // The BlobServiceClient is created before the health check is run. + // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so + // we use BeforeResourceStartedEvent + + var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null."); + blobServiceClient = CreateBlobServiceClient(connectionString); + }) + .OnResourceReady(async (storage, @event, ct) => { - var blobContainerClient = blobServiceClient.GetBlobContainerClient(container.BlobContainerName); - await blobContainerClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); - } - }); + // The ResourceReadyEvent of a resource is triggered after its health check is healthy. + // This means we can safely use this event to create the blob containers. + + if (blobServiceClient is null) + { + throw new InvalidOperationException("BlobServiceClient is not initialized."); + } + + foreach (var container in builder.Resource.BlobContainers) + { + var blobContainerClient = blobServiceClient.GetBlobContainerClient(container.BlobContainerName); + await blobContainerClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); + } + }); var healthCheckKey = $"{builder.Resource.Name}_check"; @@ -295,10 +295,6 @@ public static IResourceBuilder AddBlobs(this IResource var resource = new AzureBlobStorageResource(name, builder.Resource); string? connectionString = null; - builder.ApplicationBuilder.Eventing.Subscribe(resource, async (@event, ct) => - { - connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); - }); var healthCheckKey = $"{resource.Name}_check"; @@ -308,7 +304,13 @@ public static IResourceBuilder AddBlobs(this IResource return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized.")); }, name: healthCheckKey); - return builder.ApplicationBuilder.AddResource(resource).WithHealthCheck(healthCheckKey); + return builder.ApplicationBuilder + .AddResource(resource) + .WithHealthCheck(healthCheckKey) + .OnConnectionStringAvailable(async (blobs, @event, ct) => + { + connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + }); } /// @@ -329,9 +331,9 @@ public static IResourceBuilder AddBlobContain builder.Resource.Parent.BlobContainers.Add(resource); string? connectionString = null; - builder.ApplicationBuilder.Eventing.Subscribe(resource, async (@event, ct) => + builder.OnConnectionStringAvailable(async (blobStorage, @event, ct) => { - connectionString = await resource.Parent.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + connectionString = await blobStorage.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); }); var healthCheckKey = $"{resource.Name}_check"; diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 563511f9932..a45888cff0d 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -45,37 +45,6 @@ public static IResourceBuilder AddSqlServer(this IDistr string? connectionString = null; - builder.Eventing.Subscribe(sqlServer, async (@event, ct) => - { - connectionString = await sqlServer.GetConnectionStringAsync(ct).ConfigureAwait(false); - - if (connectionString == null) - { - throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{sqlServer.Name}' resource but the connection string was null."); - } - }); - - builder.Eventing.Subscribe(sqlServer, async (@event, ct) => - { - if (connectionString is null) - { - throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{sqlServer.Name}' resource but the connection string was null."); - } - - using var sqlConnection = new SqlConnection(connectionString); - await sqlConnection.OpenAsync(ct).ConfigureAwait(false); - - if (sqlConnection.State != System.Data.ConnectionState.Open) - { - throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); - } - - foreach (var sqlDatabase in sqlServer.DatabaseResources) - { - await CreateDatabaseAsync(sqlConnection, sqlDatabase, @event.Services, ct).ConfigureAwait(false); - } - }); - var healthCheckKey = $"{name}_check"; builder.Services.AddHealthChecks().AddSqlServer(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); @@ -88,7 +57,36 @@ public static IResourceBuilder AddSqlServer(this IDistr { context.EnvironmentVariables["MSSQL_SA_PASSWORD"] = sqlServer.PasswordParameter; }) - .WithHealthCheck(healthCheckKey); + .WithHealthCheck(healthCheckKey) + .OnConnectionStringAvailable(async (sqlServer, @event, ct) => + { + connectionString = await sqlServer.GetConnectionStringAsync(ct).ConfigureAwait(false); + + if (connectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{sqlServer.Name}' resource but the connection string was null."); + } + }) + .OnResourceReady(async (sqlServer, @event, ct) => + { + if (connectionString is null) + { + throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{sqlServer.Name}' resource but the connection string was null."); + } + + using var sqlConnection = new SqlConnection(connectionString); + await sqlConnection.OpenAsync(ct).ConfigureAwait(false); + + if (sqlConnection.State != System.Data.ConnectionState.Open) + { + throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); + } + + foreach (var sqlDatabase in sqlServer.DatabaseResources) + { + await CreateDatabaseAsync(sqlConnection, sqlDatabase, @event.Services, ct).ConfigureAwait(false); + } + }); } /// @@ -112,22 +110,21 @@ public static IResourceBuilder AddDatabase(this IReso string? connectionString = null; - builder.ApplicationBuilder.Eventing.Subscribe(sqlServerDatabase, async (@event, ct) => - { - connectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); - - if (connectionString == null) - { - throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null."); - } - }); - var healthCheckKey = $"{name}_check"; builder.ApplicationBuilder.Services.AddHealthChecks().AddSqlServer(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); return builder.ApplicationBuilder .AddResource(sqlServerDatabase) - .WithHealthCheck(healthCheckKey); + .WithHealthCheck(healthCheckKey) + .OnConnectionStringAvailable(async (sqlServerDatabase, @event, ct) => + { + connectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + + if (connectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null."); + } + }); } /// diff --git a/src/Aspire.Hosting/EventingExtensions.cs b/src/Aspire.Hosting/EventingExtensions.cs new file mode 100644 index 00000000000..fcd6668baaa --- /dev/null +++ b/src/Aspire.Hosting/EventingExtensions.cs @@ -0,0 +1,76 @@ +// 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; +using Aspire.Hosting.Eventing; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for subscribing to events on resources. +/// +public static class EventingExtensions +{ + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The resource type. + /// The resource builder. + /// A callback to handle the event. + /// The . + public static IResourceBuilder OnBeforeResourceStarted(this IResourceBuilder builder, Func callback) + where T : IResource + => builder.OnEvent(callback); + + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The resource type. + /// The resource builder. + /// A callback to handle the event. + /// The . + public static IResourceBuilder OnConnectionStringAvailable(this IResourceBuilder builder, Func callback) + where T : IResourceWithConnectionString + => builder.OnEvent(callback); + + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The resource type. + /// The resource builder. + /// A callback to handle the event. + /// The . + public static IResourceBuilder OnInitializeResource(this IResourceBuilder builder, Func callback) + where T : IResource + => builder.OnEvent(callback); + + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The resource type. + /// The resource builder. + /// A callback to handle the event. + /// The . + public static IResourceBuilder OnResourceEndpointsAllocated(this IResourceBuilder builder, Func callback) + where T : IResourceWithEndpoints + => builder.OnEvent(callback); + + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The resource type. + /// The resource builder. + /// A callback to handle the event. + /// The . + public static IResourceBuilder OnResourceReady(this IResourceBuilder builder, Func callback) + where T : IResource + => builder.OnEvent(callback); + + private static IResourceBuilder OnEvent(this IResourceBuilder builder, Func callback) + where TResource : IResource + where TEvent : IDistributedApplicationResourceEvent + { + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, (evt, ct) => callback(builder.Resource, evt, ct)); + return builder; + } +} diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 7ea16c90e91..bdca1bafd6d 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1341,7 +1341,7 @@ public static IResourceBuilder WithHttpHealthCheck(this IResourceBuilder(builder.Resource, (@event, ct) => + builder.OnResourceEndpointsAllocated((_, @event, ct) => { if (!endpoint.Exists) { @@ -1352,7 +1352,7 @@ public static IResourceBuilder WithHttpHealthCheck(this IResourceBuilder(builder.Resource, (@event, ct) => + builder.OnBeforeResourceStarted((_, @event, ct) => { var baseUri = new Uri(endpoint.Url, UriKind.Absolute); uri = new Uri(baseUri, path);