From 53d0fe367c82fb984a4387ae3c578a0c286a1f6f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 2 Mar 2024 03:58:39 -0800 Subject: [PATCH] Azure provisioning in the dashboard (#2552) This change makes it possible (and cleaner IMO) to publish and log data about a resource to the dashboard and does that to show errors when creating resources and for showing provisioning progress when using the Aspire.Hosting.Azure.Provisioning package. This change consolidates the previously made annotations for publishing and subscribing to both resource updates and logs general purpose services instead of annotations. --- .../CustomResources.AppHost/TestResource.cs | 62 ++-- .../bicep/BicepSample.ApiService/Program.cs | 2 +- .../bicep/BicepSample.AppHost/Program.cs | 2 +- .../EndpointsColumnDisplay.razor | 2 +- .../Model/ResourceEndpointHelpers.cs | 61 ++- src/Aspire.Dashboard/Resources/Resources.resx | 56 +-- .../AzureProvisionerExtensions.cs | 2 +- .../Provisioners/AzureProvisioner.cs | 151 ++++++-- .../AzureResourceProvisionerOfT.cs | 10 +- .../Provisioners/BicepProvisioner.cs | 149 +++++++- .../AzureAppConfigurationResource.cs | 15 + .../AzureApplicationInsightsResource.cs | 15 + .../AzureBicepResource.cs | 33 ++ .../AzureBlobStorageResource.cs | 15 + .../AzureCosmosDBResource.cs | 15 + .../AzureKeyVaultResource.cs | 15 + .../AzureOpenAIResource.cs | 6 +- .../AzurePostgresResource.cs | 15 + .../AzureQueueStorageResource.cs | 15 + .../AzureRedisResource.cs | 15 + .../AzureSearchResource.cs | 17 +- .../AzureServiceBusResource.cs | 15 + .../AzureSignalRResource.cs | 16 + .../AzureSqlServerResource.cs | 15 + .../AzureTableStorageResource.cs | 15 + .../AzureBicepResourceExtensions.cs | 29 +- src/Aspire.Hosting.Azure/IAzureResource.cs | 4 + .../CustomResourceExtensions.cs | 37 +- .../CustomResourceKnownProperties.cs | 17 + .../CustomResourceSnapshot.cs | 42 +++ .../EnvironmentCallbackContext.cs | 7 + .../ResourceLoggerAnnotation.cs | 121 ------ .../ApplicationModel/ResourceLoggerService.cs | 168 +++++++++ .../ResourceNotificationService.cs | 177 +++++++++ .../ResourceNotificationState.cs | 15 + .../ResourceUpdatesAnnotation.cs | 170 --------- .../Dashboard/ConsoleLogPublisher.cs | 23 +- .../Dashboard/DashboardServiceData.cs | 6 +- .../Dashboard/DashboardServiceHost.cs | 6 +- src/Aspire.Hosting/Dashboard/DcpDataSource.cs | 163 +++----- src/Aspire.Hosting/Dcp/ApplicationExecutor.cs | 348 +++++++++++------- .../DistributedApplicationBuilder.cs | 2 + .../ParameterResourceBuilderExtensions.cs | 40 +- .../Extensions/ResourceBuilderExtensions.cs | 3 + .../Postgres/PostgresDatabaseResource.cs | 17 + .../SqlServer/SqlServerDatabaseResource.cs | 17 + src/Shared/Model/KnownProperties.cs | 1 + .../Dcp/ApplicationExecutorTests.cs | 4 +- .../ResourceLoggerServiceTests.cs | 75 ++++ .../ResourceLoggerTests.cs | 81 ---- .../ResourceNotificationTests.cs | 155 ++++++++ .../ResourceUpdatesTests.cs | 127 ------- 52 files changed, 1621 insertions(+), 968 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/CustomResourceKnownProperties.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs delete mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceLoggerAnnotation.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceNotificationState.cs delete mode 100644 src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs create mode 100644 tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs delete mode 100644 tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs create mode 100644 tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs delete mode 100644 tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs diff --git a/playground/CustomResources/CustomResources.AppHost/TestResource.cs b/playground/CustomResources/CustomResources.AppHost/TestResource.cs index 238c2767ea..8192748888 100644 --- a/playground/CustomResources/CustomResources.AppHost/TestResource.cs +++ b/playground/CustomResources/CustomResources.AppHost/TestResource.cs @@ -9,11 +9,10 @@ static class TestResourceExtensions { public static IResourceBuilder AddTestResource(this IDistributedApplicationBuilder builder, string name) { - builder.Services.AddLifecycleHook(); + builder.Services.TryAddLifecycleHook(); var rb = builder.AddResource(new TestResource(name)) - .WithResourceLogger() - .WithResourceUpdates(() => new() + .WithInitialState(new() { ResourceType = "Test Resource", State = "Starting", @@ -28,53 +27,44 @@ public static IResourceBuilder AddTestResource(this IDistributedAp } } -internal sealed class TestResourceLifecycleHook : IDistributedApplicationLifecycleHook, IAsyncDisposable +internal sealed class TestResourceLifecycleHook(ResourceNotificationService notificationService, ResourceLoggerService loggerService) : IDistributedApplicationLifecycleHook, IAsyncDisposable { private readonly CancellationTokenSource _tokenSource = new(); public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { - foreach (var item in appModel.Resources.OfType()) + foreach (var resource in appModel.Resources.OfType()) { - if (item.TryGetLastAnnotation(out var resourceUpdates) && - item.TryGetLastAnnotation(out var loggerAnnotation)) - { - var states = new[] { "Starting", "Running", "Finished" }; + var states = new[] { "Starting", "Running", "Finished" }; - Task.Run(async () => - { - // Simulate custom resource state changes - var state = await resourceUpdates.GetInitialSnapshotAsync(_tokenSource.Token); - var seconds = Random.Shared.Next(2, 12); + var logger = loggerService.GetLogger(resource); - state = state with - { - Properties = [.. state.Properties, ("Interval", seconds.ToString(CultureInfo.InvariantCulture))] - }; - - loggerAnnotation.Logger.LogInformation("Starting test resource {ResourceName} with update interval {Interval} seconds", item.Name, seconds); + Task.Run(async () => + { + var seconds = Random.Shared.Next(2, 12); - // This might run before the dashboard is ready to receive updates, but it will be queued. - await resourceUpdates.UpdateStateAsync(state); + logger.LogInformation("Starting test resource {ResourceName} with update interval {Interval} seconds", resource.Name, seconds); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(seconds)); + await notificationService.PublishUpdateAsync(resource, state => state with + { + Properties = [.. state.Properties, ("Interval", seconds.ToString(CultureInfo.InvariantCulture))] + }); - while (await timer.WaitForNextTickAsync(_tokenSource.Token)) - { - var randomState = states[Random.Shared.Next(0, states.Length)]; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(seconds)); - state = state with - { - State = randomState - }; + while (await timer.WaitForNextTickAsync(_tokenSource.Token)) + { + var randomState = states[Random.Shared.Next(0, states.Length)]; - loggerAnnotation.Logger.LogInformation("Test resource {ResourceName} is now in state {State}", item.Name, randomState); + await notificationService.PublishUpdateAsync(resource, state => state with + { + State = randomState + }); - await resourceUpdates.UpdateStateAsync(state); - } - }, - cancellationToken); - } + logger.LogInformation("Test resource {ResourceName} is now in state {State}", resource.Name, randomState); + } + }, + cancellationToken); } return Task.CompletedTask; diff --git a/playground/bicep/BicepSample.ApiService/Program.cs b/playground/bicep/BicepSample.ApiService/Program.cs index 8dc37c373c..fe148c3038 100644 --- a/playground/bicep/BicepSample.ApiService/Program.cs +++ b/playground/bicep/BicepSample.ApiService/Program.cs @@ -16,7 +16,7 @@ builder.AddSqlServerDbContext("db"); builder.AddNpgsqlDbContext("db2"); -builder.AddAzureCosmosDB("db3"); +builder.AddAzureCosmosDB("cosmos"); builder.AddRedis("redis"); builder.AddAzureBlobService("blob"); builder.AddAzureTableService("table"); diff --git a/playground/bicep/BicepSample.AppHost/Program.cs b/playground/bicep/BicepSample.AppHost/Program.cs index d274c5ffe5..5b35ddfa59 100644 --- a/playground/bicep/BicepSample.AppHost/Program.cs +++ b/playground/bicep/BicepSample.AppHost/Program.cs @@ -11,7 +11,7 @@ .WithParameter("test", parameter) .WithParameter("values", ["one", "two"]); -var kv = builder.AddAzureKeyVault("kv"); +var kv = builder.AddAzureKeyVault("kv3"); var appConfig = builder.AddAzureAppConfiguration("appConfig").WithParameter("sku", "standard"); var storage = builder.AddAzureStorage("storage"); // .RunAsEmulator(); diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor index 7c646a5989..87e580ccc3 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor @@ -39,7 +39,7 @@
  • @if (displayedEndpoint.Url != null) { - @displayedEndpoint.Url + @displayedEndpoint.Text } else { diff --git a/src/Aspire.Dashboard/Model/ResourceEndpointHelpers.cs b/src/Aspire.Dashboard/Model/ResourceEndpointHelpers.cs index 18e51b60f8..6509e8a93c 100644 --- a/src/Aspire.Dashboard/Model/ResourceEndpointHelpers.cs +++ b/src/Aspire.Dashboard/Model/ResourceEndpointHelpers.cs @@ -12,28 +12,63 @@ internal static class ResourceEndpointHelpers /// public static List GetEndpoints(ILogger logger, ResourceViewModel resource, bool excludeServices = false, bool includeEndpointUrl = false) { + var isKnownResourceType = resource.IsContainer() || resource.IsExecutable(allowSubtypes: false) || resource.IsProject(); + var displayedEndpoints = new List(); - if (!excludeServices) + if (isKnownResourceType) { - foreach (var service in resource.Services) + if (!excludeServices) { - displayedEndpoints.Add(new DisplayedEndpoint + foreach (var service in resource.Services) { - Name = service.Name, - Text = service.AddressAndPort, - Address = service.AllocatedAddress, - Port = service.AllocatedPort - }); + displayedEndpoints.Add(new DisplayedEndpoint + { + Name = service.Name, + Text = service.AddressAndPort, + Address = service.AllocatedAddress, + Port = service.AllocatedPort + }); + } } - } - foreach (var endpoint in resource.Endpoints) + foreach (var endpoint in resource.Endpoints) + { + ProcessUrl(logger, resource, displayedEndpoints, endpoint.ProxyUrl, "ProxyUrl"); + if (includeEndpointUrl) + { + ProcessUrl(logger, resource, displayedEndpoints, endpoint.EndpointUrl, "EndpointUrl"); + } + } + } + else { - ProcessUrl(logger, resource, displayedEndpoints, endpoint.ProxyUrl, "ProxyUrl"); - if (includeEndpointUrl) + // Look for services with an address (which might be a URL) and use that to match up with endpoints. + // otherwise, just display the endpoints. + var addressLookup = resource.Services.Where(s => s.AllocatedAddress is not null) + .ToDictionary(s => s.AllocatedAddress!); + + foreach (var endpoint in resource.Endpoints) { - ProcessUrl(logger, resource, displayedEndpoints, endpoint.EndpointUrl, "EndpointUrl"); + if (addressLookup.TryGetValue(endpoint.EndpointUrl, out var service)) + { + displayedEndpoints.Add(new DisplayedEndpoint + { + Name = service.Name, + Url = endpoint.EndpointUrl, + Text = service.Name, + Address = service.AllocatedAddress, + Port = service.AllocatedPort + }); + } + else + { + displayedEndpoints.Add(new DisplayedEndpoint + { + Name = endpoint.EndpointUrl, + Text = endpoint.EndpointUrl + }); + } } } diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index d086c69a72..a835acb2fc 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -1,17 +1,17 @@ - @@ -215,4 +215,4 @@ State - + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Provisioning/AzureProvisionerExtensions.cs b/src/Aspire.Hosting.Azure.Provisioning/AzureProvisionerExtensions.cs index 286858c830..315e9fc6c2 100644 --- a/src/Aspire.Hosting.Azure.Provisioning/AzureProvisionerExtensions.cs +++ b/src/Aspire.Hosting.Azure.Provisioning/AzureProvisionerExtensions.cs @@ -22,7 +22,7 @@ public static class AzureProvisionerExtensions /// public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistributedApplicationBuilder builder) { - builder.Services.AddLifecycleHook(); + builder.Services.TryAddLifecycleHook(); // Attempt to read azure configuration from configuration builder.Services.AddOptions() diff --git a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs index 100283dfbb..332a1e24c3 100644 --- a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs @@ -29,7 +29,9 @@ internal sealed class AzureProvisioner( IHostEnvironment environment, ILogger logger, IServiceProvider serviceProvider, - IEnumerable resourceEnumerators) : IDistributedApplicationLifecycleHook + IEnumerable resourceEnumerators, + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) : IDistributedApplicationLifecycleHook { internal const string AspireResourceNameTag = "aspire-resource-name"; @@ -52,24 +54,32 @@ private static IResource PromoteAzureResourceFromAnnotation(IResource resource) } } - public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { // TODO: Make this more general purpose if (executionContext.IsPublishMode) { - return; + return Task.CompletedTask; } - var azureResources = appModel.Resources.Select(PromoteAzureResourceFromAnnotation).OfType(); - if (!azureResources.OfType().Any()) + var azureResources = appModel.Resources.Select(PromoteAzureResourceFromAnnotation).OfType().ToList(); + if (azureResources.Count == 0) { - return; + return Task.CompletedTask; } - await ProvisionAzureResources(configuration, environment, logger, azureResources, cancellationToken).ConfigureAwait(false); + foreach (var r in azureResources) + { + r.ProvisioningTaskCompletionSource = new(); + } + + // This is fuly async so we can just fire and forget + _ = Task.Run(() => ProvisionAzureResources(configuration, environment, logger, azureResources, cancellationToken), cancellationToken); + + return Task.CompletedTask; } - private async Task ProvisionAzureResources(IConfiguration configuration, IHostEnvironment environment, ILogger logger, IEnumerable azureResources, CancellationToken cancellationToken) + private async Task ProvisionAzureResources(IConfiguration configuration, IHostEnvironment environment, ILogger logger, IList azureResources, CancellationToken cancellationToken) { var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions() { @@ -86,7 +96,7 @@ private async Task ProvisionAzureResources(IConfiguration configuration, IHostEn return new ArmClient(credential, subscriptionId); }); - var subscriptionLazy = new Lazy>(async () => + var subscriptionLazy = new Lazy>(async () => { logger.LogInformation("Getting default subscription..."); @@ -94,7 +104,18 @@ private async Task ProvisionAzureResources(IConfiguration configuration, IHostEn logger.LogInformation("Default subscription: {name} ({subscriptionId})", value.Data.DisplayName, value.Id); - return value; + logger.LogInformation("Getting tenant..."); + + await foreach (var tenant in armClientLazy.Value.GetTenants().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (tenant.Data.TenantId == value.Data.TenantId) + { + logger.LogInformation("Tenant: {tenantId}", tenant.Data.TenantId); + return (value, tenant); + } + } + + throw new InvalidOperationException($"Could not find tenant id {value.Data.TenantId} for subscription {value.Data.DisplayName}."); }); Lazy> resourceGroupAndLocationLazy = new(async () => @@ -112,7 +133,7 @@ private async Task ProvisionAzureResources(IConfiguration configuration, IHostEn string rg => (rg, _options.AllowResourceGroupCreation ?? false) }; - var subscription = await subscriptionLazy.Value.ConfigureAwait(false); + var (subscription, _) = await subscriptionLazy.Value.ConfigureAwait(false); var resourceGroups = subscription.GetResourceGroups(); ResourceGroupResource? resourceGroup = null; @@ -185,6 +206,7 @@ await PopulateExistingAspireResources( ResourceGroupResource? resourceGroup = null; SubscriptionResource? subscription = null; + TenantResource? tenant = null; Dictionary? resourceMap = null; UserPrincipal? principal = null; ProvisioningContext? provisioningContext = null; @@ -219,44 +241,102 @@ await PopulateExistingAspireResources( var provisioner = SelectProvisioner(resource); + var resourceLogger = loggerService.GetLogger(resource); + if (provisioner is null) { - logger.LogWarning("No provisioner found for {resourceType} skipping.", resource.GetType().Name); + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + + resourceLogger.LogWarning("No provisioner found for {resourceType} skipping.", resource.GetType().Name); + + await notificationService.PublishUpdateAsync(resource, state => state with { State = "Running" }).ConfigureAwait(false); + continue; } if (!provisioner.ShouldProvision(configuration, resource)) { - logger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.Name); + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + + resourceLogger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.Name); + + await notificationService.PublishUpdateAsync(resource, state => state with { State = "Running" }).ConfigureAwait(false); + continue; } - if (provisioner.ConfigureResource(configuration, resource)) + if (await provisioner.ConfigureResourceAsync(configuration, resource, cancellationToken).ConfigureAwait(false)) { - logger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.Name); + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + + await notificationService.PublishUpdateAsync(resource, state => state with { State = "Running" }).ConfigureAwait(false); + + resourceLogger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.Name); continue; } - subscription ??= await subscriptionLazy.Value.ConfigureAwait(false); - - AzureLocation location = default; + resourceLogger.LogInformation("Provisioning {resourceName}...", resource.Name); - if (resourceGroup is null) + try { - (resourceGroup, location) = await resourceGroupAndLocationLazy.Value.ConfigureAwait(false); - } + if (subscription is null || tenant is null) + { + (subscription, tenant) = await subscriptionLazy.Value.ConfigureAwait(false); + } - resourceMap ??= await resourceMapLazy.Value.ConfigureAwait(false); - principal ??= await principalLazy.Value.ConfigureAwait(false); - provisioningContext ??= new ProvisioningContext(credential, armClientLazy.Value, subscription, resourceGroup, resourceMap, location, principal, userSecrets); + AzureLocation location = default; - var task = provisioner.GetOrCreateResourceAsync( - resource, - provisioningContext, - cancellationToken); + if (resourceGroup is null) + { + (resourceGroup, location) = await resourceGroupAndLocationLazy.Value.ConfigureAwait(false); + } - tasks.Add(task); + resourceMap ??= await resourceMapLazy.Value.ConfigureAwait(false); + principal ??= await principalLazy.Value.ConfigureAwait(false); + provisioningContext ??= new ProvisioningContext(credential, armClientLazy.Value, subscription, resourceGroup, tenant, resourceMap, location, principal, userSecrets); + + var task = provisioner.GetOrCreateResourceAsync( + resource, + provisioningContext, + cancellationToken); + + static async Task AfterProvision(ILogger resourceLogger, ResourceNotificationService ns, Task task, IAzureResource resource) + { + try + { + await task.ConfigureAwait(false); + + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Error provisioning {resourceName}.", resource.Name); + + resource.ProvisioningTaskCompletionSource?.TrySetException(new InvalidOperationException($"Unable to resolve references from {resource.Name}")); + + await ns.PublishUpdateAsync(resource, state => state with + { + State = "FailedToStart" + }) + .ConfigureAwait(false); + } + } + + tasks.Add(AfterProvision(resourceLogger, notificationService, task, resource)); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Error provisioning {resourceName}.", resource.Name); + + resource.ProvisioningTaskCompletionSource?.TrySetException(new InvalidOperationException($"Unable to resolve references from {resource.Name}")); + + await notificationService.PublishUpdateAsync(resource, state => state with + { + State = "FailedToStart" + }) + .ConfigureAwait(false); + } } if (tasks.Count > 0) @@ -275,16 +355,19 @@ await PopulateExistingAspireResources( logger.LogInformation("Azure resource connection strings saved to user secrets."); } - - // Throw if any of the tasks failed, but after we've saved to user secrets - await task.ConfigureAwait(false); + } + else + { + // Set the completion source for all resources + foreach (var resource in azureResources) + { + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + } } // Do this in the background to avoid blocking startup _ = Task.Run(async () => { - logger.LogInformation("Cleaning up unused resources..."); - resourceMap ??= await resourceMapLazy.Value.ConfigureAwait(false); // Clean up any left over resources that are no longer in the model diff --git a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureResourceProvisionerOfT.cs b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureResourceProvisionerOfT.cs index 9765433c10..734a38277d 100644 --- a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureResourceProvisionerOfT.cs +++ b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureResourceProvisionerOfT.cs @@ -20,6 +20,7 @@ internal sealed class ProvisioningContext( ArmClient armClient, SubscriptionResource subscription, ResourceGroupResource resourceGroup, + TenantResource tenant, IReadOnlyDictionary resourceMap, AzureLocation location, UserPrincipal principal, @@ -28,6 +29,7 @@ internal sealed class ProvisioningContext( public TokenCredential Credential => credential; public ArmClient ArmClient => armClient; public SubscriptionResource Subscription => subscription; + public TenantResource Tenant => tenant; public ResourceGroupResource ResourceGroup => resourceGroup; public IReadOnlyDictionary ResourceMap => resourceMap; public AzureLocation Location => location; @@ -37,7 +39,7 @@ internal sealed class ProvisioningContext( internal interface IAzureResourceProvisioner { - bool ConfigureResource(IConfiguration configuration, IAzureResource resource); + Task ConfigureResourceAsync(IConfiguration configuration, IAzureResource resource, CancellationToken cancellationToken); bool ShouldProvision(IConfiguration configuration, IAzureResource resource); @@ -50,8 +52,8 @@ Task GetOrCreateResourceAsync( internal abstract class AzureResourceProvisioner : IAzureResourceProvisioner where TResource : IAzureResource { - bool IAzureResourceProvisioner.ConfigureResource(IConfiguration configuration, IAzureResource resource) => - ConfigureResource(configuration, (TResource)resource); + Task IAzureResourceProvisioner.ConfigureResourceAsync(IConfiguration configuration, IAzureResource resource, CancellationToken cancellationToken) => + ConfigureResourceAsync(configuration, (TResource)resource, cancellationToken); bool IAzureResourceProvisioner.ShouldProvision(IConfiguration configuration, IAzureResource resource) => ShouldProvision(configuration, (TResource)resource); @@ -62,7 +64,7 @@ Task IAzureResourceProvisioner.GetOrCreateResourceAsync( CancellationToken cancellationToken) => GetOrCreateResourceAsync((TResource)resource, context, cancellationToken); - public abstract bool ConfigureResource(IConfiguration configuration, TResource resource); + public abstract Task ConfigureResourceAsync(IConfiguration configuration, TResource resource, CancellationToken cancellationToken); public virtual bool ShouldProvision(IConfiguration configuration, TResource resource) => true; diff --git a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/BicepProvisioner.cs index 0373bd52ba..90d2c6b4da 100644 --- a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/BicepProvisioner.cs @@ -1,5 +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.Collections.Immutable; using System.Diagnostics; using System.IO.Hashing; using System.Text; @@ -7,6 +8,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; using Azure; +using Azure.ResourceManager; using Azure.ResourceManager.KeyVault; using Azure.ResourceManager.KeyVault.Models; using Azure.ResourceManager.Resources; @@ -17,12 +19,14 @@ namespace Aspire.Hosting.Azure.Provisioning; -internal sealed class BicepProvisioner(ILogger logger) : AzureResourceProvisioner +internal sealed class BicepProvisioner(ILogger logger, + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) : AzureResourceProvisioner { public override bool ShouldProvision(IConfiguration configuration, AzureBicepResource resource) => !resource.IsContainer(); - public override bool ConfigureResource(IConfiguration configuration, AzureBicepResource resource) + public override async Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) { var section = configuration.GetSection($"Azure:Deployments:{resource.Name}"); @@ -39,9 +43,32 @@ public override bool ConfigureResource(IConfiguration configuration, AzureBicepR return false; } - foreach (var item in section.GetSection("Outputs").GetChildren()) + var resourceIds = section.GetSection("ResourceIds"); + + if (section["Outputs"] is string outputJson) { - resource.Outputs[item.Key] = item.Value; + JsonNode? outputObj = null; + try + { + outputObj = JsonNode.Parse(outputJson); + + if (outputObj is null) + { + return false; + } + } + catch + { + // Unable to parse the JSON, to treat it as not existing + return false; + } + + foreach (var item in outputObj.AsObject()) + { + // TODO: Handle complex output types + // Populate the resource outputs + resource.Outputs[item.Key] = item.Value?.Prop("value").ToString(); + } } foreach (var item in section.GetSection("SecretOutputs").GetChildren()) @@ -49,11 +76,57 @@ public override bool ConfigureResource(IConfiguration configuration, AzureBicepR resource.SecretOutputs[item.Key] = item.Value; } + var portalUrls = new List<(string, string)>(); + foreach (var pair in resourceIds.GetChildren()) + { + portalUrls.Add((pair.Key, $"https://portal.azure.com/#@{configuration["Azure:Tenant"]}/resource{pair.Value}/overview")); + } + + // TODO: Figure out how to show the deployment in the portal + //var deploymentId = section["Id"]; + //if (deploymentId is not null) + //{ + // portalUrls.Add(("deployment", $"https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id/resource{Uri.EscapeDataString(deploymentId)}")); + //} + + await notificationService.PublishUpdateAsync(resource, state => + { + ImmutableArray<(string, string)> props = [ + .. state.Properties, + ("azure.subscription.id", configuration["Azure:SubscriptionId"] ?? ""), + // ("azure.resource.group", configuration["Azure:ResourceGroup"]!), + ("azure.tenant.domain", configuration["Azure:Tenant"] ?? ""), + ("azure.location", configuration["Azure:Location"] ?? ""), + (CustomResourceKnownProperties.Source, section["Id"] ?? "") + ]; + + return state with + { + State = "Running", + Urls = [.. portalUrls], + Properties = props + }; + }).ConfigureAwait(false); + return true; } public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) { + await notificationService.PublishUpdateAsync(resource, state => state with + { + ResourceType = resource.GetType().Name, + State = "Starting", + Properties = [ + ("azure.subscription.id", context.Subscription.Id.Name), + ("azure.resource.group", context.ResourceGroup.Id.Name), + ("azure.tenant.domain", context.Tenant.Data.DefaultDomain), + ("azure.location", context.Location.ToString()), + ] + }).ConfigureAwait(false); + + var resourceLogger = loggerService.GetLogger(resource); + PopulateWellKnownParameters(resource, context); var azPath = FindFullPathFromPath("az") ?? @@ -76,7 +149,7 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, { if (kv.Data.Tags.TryGetValue("aspire-secret-store", out var secretStore) && secretStore == resource.Name) { - logger.LogInformation("Found key vault {vaultName} for resource {resource} in {location}...", kv.Data.Name, resource.Name, context.Location); + resourceLogger.LogInformation("Found key vault {vaultName} for resource {resource} in {location}...", kv.Data.Name, resource.Name, context.Location); keyVault = kv; break; @@ -89,7 +162,7 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, // Follow this link for more information: https://go.microsoft.com/fwlink/?linkid=2147742 var vaultName = $"v{Guid.NewGuid().ToString("N")[0..20]}"; - logger.LogInformation("Creating key vault {vaultName} for resource {resource} in {location}...", vaultName, resource.Name, context.Location); + resourceLogger.LogInformation("Creating key vault {vaultName} for resource {resource} in {location}...", vaultName, resource.Name, context.Location); var properties = new KeyVaultProperties(context.Subscription.Data.TenantId!.Value, new KeyVaultSku(KeyVaultSkuFamily.A, KeyVaultSkuName.Standard)) { @@ -102,7 +175,7 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, var kvOperation = await keyVaults.CreateOrUpdateAsync(WaitUntil.Completed, vaultName, kvParameters, cancellationToken).ConfigureAwait(false); keyVault = kvOperation.Value; - logger.LogInformation("Key vault {vaultName} created.", keyVault.Data.Name); + resourceLogger.LogInformation("Key vault {vaultName} created.", keyVault.Data.Name); // Key Vault Administrator // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-administrator @@ -130,14 +203,17 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, var deployments = context.ResourceGroup.GetArmDeployments(); - logger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, context.ResourceGroup.Data.Name); + resourceLogger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, context.ResourceGroup.Data.Name); // Convert the parameters to a JSON object var parameters = new JsonObject(); SetParameters(parameters, resource); var sw = Stopwatch.StartNew(); - var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Completed, resource.Name, new ArmDeploymentContent(new(ArmDeploymentMode.Incremental) + + ArmOperation operation; + + operation = await deployments.CreateOrUpdateAsync(WaitUntil.Completed, resource.Name, new ArmDeploymentContent(new(ArmDeploymentMode.Incremental) { Template = BinaryData.FromString(armTemplateContents.ToString()), Parameters = BinaryData.FromObjectAsJson(parameters), @@ -146,7 +222,7 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, cancellationToken).ConfigureAwait(false); sw.Stop(); - logger.LogInformation("Deployment of {Name} to {ResourceGroup} took {Elapsed}", resource.Name, context.ResourceGroup.Data.Name, sw.Elapsed); + resourceLogger.LogInformation("Deployment of {Name} to {ResourceGroup} took {Elapsed}", resource.Name, context.ResourceGroup.Data.Name, sw.Elapsed); var deployment = operation.Value; @@ -165,34 +241,52 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, var outputObj = outputs?.ToObjectFromJson(); + var az = context.UserSecrets.Prop("Azure"); + az["Tenant"] = context.Tenant.Data.DefaultDomain; + var resourceConfig = context.UserSecrets .Prop("Azure") .Prop("Deployments") .Prop(resource.Name); + // TODO: Clear the entire section if the deployment + + // Save the deployment id to the configuration + resourceConfig["Id"] = deployment.Id.ToString(); + // Stash all parameters as a single JSON string resourceConfig["Parameters"] = parameters.ToJsonString(); + if (outputObj is not null) + { + // Same for outputs + resourceConfig["Outputs"] = outputObj.ToJsonString(); + } + // Save the checksum to the configuration resourceConfig["CheckSum"] = GetChecksum(resource, parameters); - if (outputObj is not null) + // Save the resource ids created + var resourceIdConfig = resourceConfig.Prop("ResourceIds"); + var portalUrls = new List<(string, string)>(); + + foreach (var item in deployment.Data.Properties.OutputResources) { - // TODO: Make this more robust - var configOutputs = resourceConfig.Prop("Outputs"); + resourceIdConfig[item.Id.Name] = item.Id.ToString(); + portalUrls.Add((item.Id.Name, $"https://portal.azure.com/#@{context.Tenant.Data.DefaultDomain}/resource{item.Id}/overview")); + } + // TODO: Figure out how to show the deployment in the portal + // portalUrls.Add(("deployment", $"https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id/resource{deployment.Id}")); + + if (outputObj is not null) + { foreach (var item in outputObj.AsObject()) { // TODO: Handle complex output types // Populate the resource outputs resource.Outputs[item.Key] = item.Value?.Prop("value").ToString(); } - - foreach (var item in resource.Outputs) - { - // Save them to configuration - configOutputs[item.Key] = resource.Outputs[item.Key]; - } } // Populate secret outputs from key vault (if any) @@ -215,6 +309,23 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, configOutputs[item.Key] = resource.SecretOutputs[item.Key]; } } + + await notificationService.PublishUpdateAsync(resource, state => + { + ImmutableArray<(string, string)> properties = [ + .. state.Properties, + (CustomResourceKnownProperties.Source, deployment.Id.Name) + ]; + + return state with + { + State = "Running", + CreationTimeStamp = DateTime.UtcNow, + Properties = properties, + Urls = [.. portalUrls] + }; + }) + .ConfigureAwait(false); } private static void PopulateWellKnownParameters(AzureBicepResource resource, ProvisioningContext context) diff --git a/src/Aspire.Hosting.Azure/AzureAppConfigurationResource.cs b/src/Aspire.Hosting.Azure/AzureAppConfigurationResource.cs index d258824391..f80bdd1262 100644 --- a/src/Aspire.Hosting.Azure/AzureAppConfigurationResource.cs +++ b/src/Aspire.Hosting.Azure/AzureAppConfigurationResource.cs @@ -28,4 +28,19 @@ public class AzureAppConfigurationResource(string name) : /// /// The connection string for the Azure App Configuration resource. public string? GetConnectionString() => Endpoint.Value; + + /// + /// Gets the connection string for the Azure App Configuration resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure App Configuration resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } } diff --git a/src/Aspire.Hosting.Azure/AzureApplicationInsightsResource.cs b/src/Aspire.Hosting.Azure/AzureApplicationInsightsResource.cs index 7f578cad1c..380d00f8dd 100644 --- a/src/Aspire.Hosting.Azure/AzureApplicationInsightsResource.cs +++ b/src/Aspire.Hosting.Azure/AzureApplicationInsightsResource.cs @@ -29,6 +29,21 @@ public class AzureApplicationInsightsResource(string name) : /// The connection string for the Azure Application Insights resource. public string? GetConnectionString() => ConnectionString.Value; + /// + /// Gets the connection string for the Azure Application Insights resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Application Insights resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + // UseAzureMonitor is looks for this specific environment variable name. string IResourceWithConnectionString.ConnectionStringEnvironmentVariable => "APPLICATIONINSIGHTS_CONNECTION_STRING"; } diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index 1217f773da..4d9e2cf6ae 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -41,6 +41,11 @@ public class AzureBicepResource(string name, string? templateFile = null, string /// public Dictionary SecretOutputs { get; } = []; + /// + /// The task completion source for the provisioning operation. + /// + public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } + /// /// Gets the path to the bicep file. If the template is a string or embedded resource, it will be written to a temporary file. /// @@ -248,6 +253,20 @@ public class BicepSecretOutputReference(string name, AzureBicepResource resource /// public AzureBicepResource Resource { get; } = resource; + /// + /// The value of the output. + /// + /// A to observe while waiting for the task to complete. + public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + if (Resource.ProvisioningTaskCompletionSource is not null) + { + await Resource.ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return Value; + } + /// /// The value of the output. /// @@ -286,6 +305,20 @@ public class BicepOutputReference(string name, AzureBicepResource resource) /// public AzureBicepResource Resource { get; } = resource; + /// + /// The value of the output. + /// + /// A to observe while waiting for the task to complete. + public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + if (Resource.ProvisioningTaskCompletionSource is not null) + { + await Resource.ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return Value; + } + /// /// The value of the output. /// diff --git a/src/Aspire.Hosting.Azure/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure/AzureBlobStorageResource.cs index 48512d25c7..6aea8d394c 100644 --- a/src/Aspire.Hosting.Azure/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBlobStorageResource.cs @@ -31,6 +31,21 @@ public class AzureBlobStorageResource(string name, AzureStorageResource storage) /// The connection string for the Azure Blob Storage resource. public string? GetConnectionString() => Parent.GetBlobConnectionString(); + /// + /// Gets the connection string for the Azure Blob Storage resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Blob Storage resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (Parent.ProvisioningTaskCompletionSource is not null) + { + await Parent.ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + /// /// Called by manifest publisher to write manifest resource. /// diff --git a/src/Aspire.Hosting.Azure/AzureCosmosDBResource.cs b/src/Aspire.Hosting.Azure/AzureCosmosDBResource.cs index 70cb3aa310..acbd726f82 100644 --- a/src/Aspire.Hosting.Azure/AzureCosmosDBResource.cs +++ b/src/Aspire.Hosting.Azure/AzureCosmosDBResource.cs @@ -32,6 +32,21 @@ public class AzureCosmosDBResource(string name) : /// public string ConnectionStringExpression => ConnectionString.ValueExpression; + /// + /// Gets the connection string to use for this database. + /// + /// A to observe while waiting for the task to complete. + /// The connection string to use for this database. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + /// /// Gets the connection string to use for this database. /// diff --git a/src/Aspire.Hosting.Azure/AzureKeyVaultResource.cs b/src/Aspire.Hosting.Azure/AzureKeyVaultResource.cs index 38e5a57d33..d093401a8e 100644 --- a/src/Aspire.Hosting.Azure/AzureKeyVaultResource.cs +++ b/src/Aspire.Hosting.Azure/AzureKeyVaultResource.cs @@ -28,4 +28,19 @@ public class AzureKeyVaultResource(string name) : /// /// The connection string for the Azure Key Vault resource. public string? GetConnectionString() => VaultUri.Value; + + /// + /// Gets the connection string for the Azure Key Vault resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Key Vault resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return new(GetConnectionString()); + } } diff --git a/src/Aspire.Hosting.Azure/AzureOpenAIResource.cs b/src/Aspire.Hosting.Azure/AzureOpenAIResource.cs index cf5e467a14..b9858b9816 100644 --- a/src/Aspire.Hosting.Azure/AzureOpenAIResource.cs +++ b/src/Aspire.Hosting.Azure/AzureOpenAIResource.cs @@ -1,6 +1,5 @@ // 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.ApplicationModel; /// @@ -27,6 +26,11 @@ public class AzureOpenAIResource(string name) : Resource(name), IAzureResource, /// public IReadOnlyList Deployments => _deployments; + /// + /// Set by the AzureProvisioner to indicate the task that is provisioning the resource. + /// + public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } + internal void AddDeployment(AzureOpenAIDeploymentResource deployment) { if (deployment.Parent != this) diff --git a/src/Aspire.Hosting.Azure/AzurePostgresResource.cs b/src/Aspire.Hosting.Azure/AzurePostgresResource.cs index 326ad849ae..987158fafa 100644 --- a/src/Aspire.Hosting.Azure/AzurePostgresResource.cs +++ b/src/Aspire.Hosting.Azure/AzurePostgresResource.cs @@ -29,6 +29,21 @@ public class AzurePostgresResource(PostgresServerResource innerResource) : /// The connection string. public string? GetConnectionString() => ConnectionString.Value; + /// + /// Gets the connection string for the Azure Postgres Flexible Server. + /// + /// A to observe while waiting for the task to complete. + /// The connection string. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + /// public override string Name => innerResource.Name; diff --git a/src/Aspire.Hosting.Azure/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure/AzureQueueStorageResource.cs index f07ba4a4ef..aa468e7d83 100644 --- a/src/Aspire.Hosting.Azure/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure/AzureQueueStorageResource.cs @@ -31,6 +31,21 @@ public class AzureQueueStorageResource(string name, AzureStorageResource storage /// The connection string for the Azure Queue Storage resource. public string? GetConnectionString() => Parent.GetQueueConnectionString(); + /// + /// Gets the connection string for the Azure Queue Storage resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Queue Storage resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (Parent.ProvisioningTaskCompletionSource is not null) + { + await Parent.ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + internal void WriteToManifest(ManifestPublishingContext context) { context.Writer.WriteString("type", "value.v0"); diff --git a/src/Aspire.Hosting.Azure/AzureRedisResource.cs b/src/Aspire.Hosting.Azure/AzureRedisResource.cs index aa6c57c2fb..6c74f47f93 100644 --- a/src/Aspire.Hosting.Azure/AzureRedisResource.cs +++ b/src/Aspire.Hosting.Azure/AzureRedisResource.cs @@ -29,6 +29,21 @@ public class AzureRedisResource(RedisResource innerResource) : /// The connection string for the Azure Redis resource. public string? GetConnectionString() => ConnectionString.Value; + /// + /// Gets the connection string for the Azure Redis resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Redis resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + /// public override string Name => innerResource.Name; diff --git a/src/Aspire.Hosting.Azure/AzureSearchResource.cs b/src/Aspire.Hosting.Azure/AzureSearchResource.cs index 342687b6fd..6507f6a2ec 100644 --- a/src/Aspire.Hosting.Azure/AzureSearchResource.cs +++ b/src/Aspire.Hosting.Azure/AzureSearchResource.cs @@ -24,9 +24,24 @@ public class AzureSearchResource(string name) : public string ConnectionStringExpression => ConnectionString.ValueExpression; /// - /// Gets the connection string for the resource. + /// Gets the connection string for the azure search resource. /// /// The connection string for the resource. public string? GetConnectionString() => ConnectionString.Value; + + /// + /// Gets the connection string for the azure search resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } } diff --git a/src/Aspire.Hosting.Azure/AzureServiceBusResource.cs b/src/Aspire.Hosting.Azure/AzureServiceBusResource.cs index 2a40f1d669..e8fcd21d43 100644 --- a/src/Aspire.Hosting.Azure/AzureServiceBusResource.cs +++ b/src/Aspire.Hosting.Azure/AzureServiceBusResource.cs @@ -31,4 +31,19 @@ public class AzureServiceBusResource(string name) : /// /// The connection string for the Azure Service Bus endpoint. public string? GetConnectionString() => ServiceBusEndpoint.Value; + + /// + /// Gets the connection string for the Azure Service Bus endpoint. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Service Bus endpoint. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } } diff --git a/src/Aspire.Hosting.Azure/AzureSignalRResource.cs b/src/Aspire.Hosting.Azure/AzureSignalRResource.cs index 68d9675dbd..e6f075ff79 100644 --- a/src/Aspire.Hosting.Azure/AzureSignalRResource.cs +++ b/src/Aspire.Hosting.Azure/AzureSignalRResource.cs @@ -22,9 +22,25 @@ public class AzureSignalRResource(string name) : /// Gets the connection string template for the manifest for Azure SignalR. /// public string ConnectionStringExpression => $"Endpoint=https://{HostName.ValueExpression};AuthType=azure"; + /// /// Gets the connection string for Azure SignalR. /// /// The connection string for Azure SignalR. public string? GetConnectionString() => $"Endpoint=https://{HostName.Value};AuthType=azure"; + + /// + /// Gets the connection string for Azure SignalR. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for Azure SignalR. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } } diff --git a/src/Aspire.Hosting.Azure/AzureSqlServerResource.cs b/src/Aspire.Hosting.Azure/AzureSqlServerResource.cs index e6842d1886..0ec72d9a97 100644 --- a/src/Aspire.Hosting.Azure/AzureSqlServerResource.cs +++ b/src/Aspire.Hosting.Azure/AzureSqlServerResource.cs @@ -33,6 +33,21 @@ public class AzureSqlServerResource(SqlServerServerResource innerResource) : return $"Server=tcp:{FullyQualifiedDomainName.Value},1433;Encrypt=True;Authentication=\"Active Directory Default\""; } + /// + /// Gets the connection string for the Azure SQL Server resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure SQL Server resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (ProvisioningTaskCompletionSource is not null) + { + await ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + /// public override string Name => innerResource.Name; diff --git a/src/Aspire.Hosting.Azure/AzureTableStorageResource.cs b/src/Aspire.Hosting.Azure/AzureTableStorageResource.cs index 4a1491f7d1..312637933f 100644 --- a/src/Aspire.Hosting.Azure/AzureTableStorageResource.cs +++ b/src/Aspire.Hosting.Azure/AzureTableStorageResource.cs @@ -31,6 +31,21 @@ public class AzureTableStorageResource(string name, AzureStorageResource storage /// The connection string for the Azure Table Storage resource. public string? GetConnectionString() => Parent.GetTableConnectionString(); + /// + /// Gets the connection string for the Azure Table Storage resource. + /// + /// A to observe while waiting for the task to complete. + /// The connection string for the Azure Table Storage resource. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (Parent.ProvisioningTaskCompletionSource is not null) + { + await Parent.ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return GetConnectionString(); + } + internal void WriteToManifest(ManifestPublishingContext context) { context.Writer.WriteString("type", "value.v0"); diff --git a/src/Aspire.Hosting.Azure/Extensions/AzureBicepResourceExtensions.cs b/src/Aspire.Hosting.Azure/Extensions/AzureBicepResourceExtensions.cs index 69ead96f36..9e69135fbd 100644 --- a/src/Aspire.Hosting.Azure/Extensions/AzureBicepResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure/Extensions/AzureBicepResourceExtensions.cs @@ -4,6 +4,7 @@ using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -74,11 +75,17 @@ public static BicepSecretOutputReference GetSecretOutput(this IResourceBuilder WithEnvironment(this IResourceBuilder builder, string name, BicepOutputReference bicepOutputReference) where T : IResourceWithEnvironment { - return builder.WithEnvironment(ctx => + return builder.WithEnvironment(async ctx => { - ctx.EnvironmentVariables[name] = ctx.ExecutionContext.IsPublishMode - ? bicepOutputReference.ValueExpression - : bicepOutputReference.Value!; + if (ctx.ExecutionContext.IsPublishMode) + { + ctx.EnvironmentVariables[name] = bicepOutputReference.ValueExpression; + return; + } + + ctx.Logger?.LogInformation("Getting bicep output {Name} from resource {ResourceName}", bicepOutputReference.Name, bicepOutputReference.Resource.Name); + + ctx.EnvironmentVariables[name] = await bicepOutputReference.GetValueAsync(ctx.CancellationToken).ConfigureAwait(false) ?? ""; }); } @@ -93,11 +100,17 @@ public static IResourceBuilder WithEnvironment(this IResourceBuilder bu public static IResourceBuilder WithEnvironment(this IResourceBuilder builder, string name, BicepSecretOutputReference bicepOutputReference) where T : IResourceWithEnvironment { - return builder.WithEnvironment(ctx => + return builder.WithEnvironment(async ctx => { - ctx.EnvironmentVariables[name] = ctx.ExecutionContext.IsPublishMode - ? bicepOutputReference.ValueExpression - : bicepOutputReference.Value!; + if (ctx.ExecutionContext.IsPublishMode) + { + ctx.EnvironmentVariables[name] = bicepOutputReference.ValueExpression; + return; + } + + ctx.Logger?.LogInformation("Getting bicep secret output {Name} from resource {ResourceName}", bicepOutputReference.Name, bicepOutputReference.Resource.Name); + + ctx.EnvironmentVariables[name] = await bicepOutputReference.GetValueAsync(ctx.CancellationToken).ConfigureAwait(false) ?? ""; }); } diff --git a/src/Aspire.Hosting.Azure/IAzureResource.cs b/src/Aspire.Hosting.Azure/IAzureResource.cs index d4e68be31f..c42516554c 100644 --- a/src/Aspire.Hosting.Azure/IAzureResource.cs +++ b/src/Aspire.Hosting.Azure/IAzureResource.cs @@ -9,4 +9,8 @@ namespace Aspire.Hosting.ApplicationModel; /// public interface IAzureResource : IResource { + /// + /// Set by the AzureProvisioner to indicate the task that is provisioning the resource. + /// + public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } } diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceExtensions.cs index 24544ab11b..85374f0c91 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceExtensions.cs @@ -11,42 +11,13 @@ namespace Aspire.Hosting; public static class CustomResourceExtensions { /// - /// Initializes the resource with a that allows publishing and subscribing to changes in the state of this resource. + /// Initializes the resource with the initial snapshot. /// /// The resource. /// The resource builder. - /// The factory to create the initial for this resource. + /// The factory to create the initial for this resource. /// The resource builder. - public static IResourceBuilder WithResourceUpdates(this IResourceBuilder builder, Func>? initialSnapshotFactory = null) + public static IResourceBuilder WithInitialState(this IResourceBuilder builder, CustomResourceSnapshot initialSnapshot) where TResource : IResource - { - initialSnapshotFactory ??= cancellationToken => CustomResourceSnapshot.CreateAsync(builder.Resource, cancellationToken); - - return builder.WithAnnotation(new ResourceUpdatesAnnotation(initialSnapshotFactory), ResourceAnnotationMutationBehavior.Replace); - } - - /// - /// Initializes the resource with a that allows publishing and subscribing to changes in the state of this resource. - /// - /// The resource. - /// The resource builder. - /// The factory to create the initial for this resource. - /// The resource builder. - public static IResourceBuilder WithResourceUpdates(this IResourceBuilder builder, Func initialSnapshotFactory) - where TResource : IResource - { - return builder.WithAnnotation(new ResourceUpdatesAnnotation(_ => ValueTask.FromResult(initialSnapshotFactory())), ResourceAnnotationMutationBehavior.Replace); - } - - /// - /// Initializes the resource with a logger that writes to the log stream for the resource. - /// - /// The resource. - /// The resource builder. - /// The resource builder. - public static IResourceBuilder WithResourceLogger(this IResourceBuilder builder) - where TResource : IResource - { - return builder.WithAnnotation(new ResourceLoggerAnnotation(), ResourceAnnotationMutationBehavior.Replace); - } + => builder.WithAnnotation(new ResourceSnapshotAnnotation(initialSnapshot), ResourceAnnotationMutationBehavior.Replace); } diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceKnownProperties.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceKnownProperties.cs new file mode 100644 index 0000000000..3451bad8c4 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceKnownProperties.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Known properties for resources that show up in the dashboard. +/// +public static class CustomResourceKnownProperties +{ + /// + /// The source of the resource + /// + public static string Source { get; } = KnownProperties.Resource.Source; +} diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs new file mode 100644 index 0000000000..7ec98d577f --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -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. + +using System.Collections.Immutable; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// An immutable snapshot of the state of a resource. +/// +public sealed record CustomResourceSnapshot +{ + /// + /// The type of the resource. + /// + public required string ResourceType { get; init; } + + /// + /// The properties that should show up in the dashboard for this resource. + /// + public required ImmutableArray<(string Key, string Value)> Properties { get; init; } + + /// + /// The creation timestamp of the resource. + /// + public DateTime? CreationTimeStamp { get; init; } + + /// + /// Represents the state of the resource. + /// + public string? State { get; init; } + + /// + /// The environment variables that should show up in the dashboard for this resource. + /// + public ImmutableArray<(string Name, string Value)> EnvironmentVariables { get; init; } = []; + + /// + /// The URLs that should show up in the dashboard for this resource. + /// + public ImmutableArray<(string Name, string Url)> Urls { get; init; } = []; +} diff --git a/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackContext.cs index b5357c8a46..b2e11583a2 100644 --- a/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/EnvironmentCallbackContext.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.Logging; + namespace Aspire.Hosting.ApplicationModel; /// @@ -27,6 +29,11 @@ public class EnvironmentCallbackContext(DistributedApplicationExecutionContext e /// public CancellationToken CancellationToken { get; } = cancellationToken; + /// + /// An optional logger to use for logging. + /// + public ILogger? Logger { get; set; } + /// /// Gets the execution context associated with this invocation of the AppHost. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerAnnotation.cs deleted file mode 100644 index c34c2d5185..0000000000 --- a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerAnnotation.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Threading.Channels; -using Aspire.Dashboard.Otlp.Storage; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// A annotation that exposes a Logger for the resource to write to. -/// -public sealed class ResourceLoggerAnnotation : IResourceAnnotation -{ - private readonly ResourceLogger _logger; - private readonly CancellationTokenSource _logStreamCts = new(); - - // History of logs, capped at 10000 entries. - private readonly CircularBuffer<(string Content, bool IsErrorMessage)> _backlog = new(10000); - - /// - /// Creates a new . - /// - public ResourceLoggerAnnotation() - { - _logger = new ResourceLogger(this); - } - - /// - /// Watch for changes to the log stream for a resource. - /// - /// The log stream for the resource. - public IAsyncEnumerable> WatchAsync() => new LogAsyncEnumerable(this); - - // This provides the fan out to multiple subscribers. - private Action<(string, bool)>? OnNewLog { get; set; } - - /// - /// The logger for the resource to write to. This will write updates to the live log stream for this resource. - /// - public ILogger Logger => _logger; - - /// - /// Close the log stream for the resource. Future subscribers will not receive any updates and will complete immediately. - /// - public void Complete() - { - // REVIEW: Do we clean up the backlog? - _logStreamCts.Cancel(); - } - - private sealed class ResourceLogger(ResourceLoggerAnnotation annotation) : ILogger - { - IDisposable? ILogger.BeginScope(TState state) => null; - - bool ILogger.IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (annotation._logStreamCts.IsCancellationRequested) - { - // Noop if logging after completing the stream - return; - } - - var log = formatter(state, exception) + (exception is null ? "" : $"\n{exception}"); - var isErrorMessage = logLevel >= LogLevel.Error; - - var payload = (log, isErrorMessage); - - lock (annotation._backlog) - { - annotation._backlog.Add(payload); - } - - annotation.OnNewLog?.Invoke(payload); - } - } - - private sealed class LogAsyncEnumerable(ResourceLoggerAnnotation annotation) : IAsyncEnumerable> - { - public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - // Yield the backlog first. - - lock (annotation._backlog) - { - if (annotation._backlog.Count > 0) - { - // REVIEW: Performance makes me very sad, but we can optimize this later. - yield return annotation._backlog.ToList(); - } - } - - var channel = Channel.CreateUnbounded<(string, bool)>(); - - using var _ = annotation._logStreamCts.Token.Register(() => channel.Writer.TryComplete()); - - void Log((string Content, bool IsErrorMessage) log) - { - channel.Writer.TryWrite(log); - } - - annotation.OnNewLog += Log; - - try - { - await foreach (var entry in channel.GetBatches(cancellationToken)) - { - yield return entry; - } - } - finally - { - annotation.OnNewLog -= Log; - - channel.Writer.TryComplete(); - } - } - } -} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs new file mode 100644 index 0000000000..0f14128f04 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Aspire.Dashboard.Otlp.Storage; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A service that provides loggers for resources to write to. +/// +public class ResourceLoggerService +{ + private readonly ConcurrentDictionary _loggers = new(); + + /// + /// Gets the logger for the resource to write to. + /// + /// The resource name + /// An . + public ILogger GetLogger(IResource resource) => + GetResourceLoggerState(resource.Name).Logger; + + /// + /// Watch for changes to the log stream for a resource. + /// + /// The resource name + /// + public IAsyncEnumerable> WatchAsync(string resourceName) => + GetResourceLoggerState(resourceName).WatchAsync(); + + /// + /// Watch for changes to the log stream for a resource. + /// + /// The resource to watch for logs. + /// + public IAsyncEnumerable> WatchAsync(IResource resource) => + WatchAsync(resource.Name); + + /// + /// Completes the log stream for the resource. + /// + /// + public void Complete(IResource resource) + { + if (_loggers.TryGetValue(resource.Name, out var logger)) + { + logger.Complete(); + } + } + private ResourceLoggerState GetResourceLoggerState(string resourceName) => + _loggers.GetOrAdd(resourceName, _ => new ResourceLoggerState()); + + /// + /// A logger for the resource to write to. + /// + private sealed class ResourceLoggerState + { + private readonly ResourceLogger _logger; + private readonly CancellationTokenSource _logStreamCts = new(); + + // History of logs, capped at 10000 entries. + private readonly CircularBuffer<(string Content, bool IsErrorMessage)> _backlog = new(10000); + + /// + /// Creates a new . + /// + public ResourceLoggerState() + { + _logger = new ResourceLogger(this); + } + + /// + /// Watch for changes to the log stream for a resource. + /// + /// The log stream for the resource. + public IAsyncEnumerable> WatchAsync() => new LogAsyncEnumerable(this); + + // This provides the fan out to multiple subscribers. + private Action<(string, bool)>? OnNewLog { get; set; } + + /// + /// The logger for the resource to write to. This will write updates to the live log stream for this resource. + /// + public ILogger Logger => _logger; + + /// + /// Close the log stream for the resource. Future subscribers will not receive any updates and will complete immediately. + /// + public void Complete() + { + // REVIEW: Do we clean up the backlog? + _logStreamCts.Cancel(); + } + + private sealed class ResourceLogger(ResourceLoggerState annotation) : ILogger + { + IDisposable? ILogger.BeginScope(TState state) => null; + + bool ILogger.IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (annotation._logStreamCts.IsCancellationRequested) + { + // Noop if logging after completing the stream + return; + } + + var log = formatter(state, exception) + (exception is null ? "" : $"\n{exception}"); + var isErrorMessage = logLevel >= LogLevel.Error; + + var payload = (log, isErrorMessage); + + lock (annotation._backlog) + { + annotation._backlog.Add(payload); + } + + annotation.OnNewLog?.Invoke(payload); + } + } + + private sealed class LogAsyncEnumerable(ResourceLoggerState annotation) : IAsyncEnumerable> + { + public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + // Yield the backlog first. + + lock (annotation._backlog) + { + if (annotation._backlog.Count > 0) + { + // REVIEW: Performance makes me very sad, but we can optimize this later. + yield return annotation._backlog.ToList(); + } + } + + var channel = Channel.CreateUnbounded<(string, bool)>(); + + using var _ = annotation._logStreamCts.Token.Register(() => channel.Writer.TryComplete()); + + void Log((string Content, bool IsErrorMessage) log) + { + channel.Writer.TryWrite(log); + } + + annotation.OnNewLog += Log; + + try + { + await foreach (var entry in channel.GetBatches(cancellationToken)) + { + yield return entry; + } + } + finally + { + annotation.OnNewLog -= Log; + + channel.Writer.TryComplete(); + } + } + } + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs new file mode 100644 index 0000000000..40ffa51e00 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A service that allows publishing and subscribing to changes in the state of a resource. +/// +public class ResourceNotificationService +{ + private readonly ConcurrentDictionary _resourceNotificationStates = new(); + + /// + /// Watch for changes to the dashboard state for a resource. + /// + /// The name of the resource + /// + public IAsyncEnumerable WatchAsync(IResource resource) + { + var notificationState = GetResourceNotificationState(resource.Name); + + lock (notificationState) + { + // When watching a resource, make sure the initial snapshot is set. + notificationState.LastSnapshot = GetInitialSnapshot(resource, notificationState); + } + + return notificationState.WatchAsync(); + } + + /// + /// Updates the snapshot of the for a resource. + /// + /// + /// + /// + public Task PublishUpdateAsync(IResource resource, CustomResourceSnapshot state) + { + return GetResourceNotificationState(resource.Name).PublishUpdateAsync(state); + } + + /// + /// Updates the snapshot of the for a resource. + /// + /// + /// + /// + public Task PublishUpdateAsync(IResource resource, Func stateFactory) + { + var notificationState = GetResourceNotificationState(resource.Name); + + lock (notificationState) + { + var previousState = GetInitialSnapshot(resource, notificationState); + + var newState = stateFactory(previousState!); + + notificationState.LastSnapshot = newState; + + return notificationState.PublishUpdateAsync(newState); + } + } + + private static CustomResourceSnapshot? GetInitialSnapshot(IResource resource, ResourceNotificationState notificationState) + { + var previousState = notificationState.LastSnapshot; + + if (previousState is null) + { + if (resource.Annotations.OfType().LastOrDefault() is { } annotation) + { + previousState = annotation.InitialSnapshot; + } + + // If there is no initial snapshot, create an empty one. + previousState ??= new CustomResourceSnapshot() + { + ResourceType = resource.GetType().Name, + Properties = [] + }; + } + + return previousState; + } + + /// + /// Signal that no more updates are expected for this resource. + /// + public void Complete(IResource resource) + { + if (_resourceNotificationStates.TryGetValue(resource.Name, out var state)) + { + state.Complete(); + } + } + + private ResourceNotificationState GetResourceNotificationState(string resourceName) => + _resourceNotificationStates.GetOrAdd(resourceName, _ => new ResourceNotificationState()); + + /// + /// The annotation that allows publishing and subscribing to changes in the state of a resource. + /// + private sealed class ResourceNotificationState + { + private readonly CancellationTokenSource _streamClosedCts = new(); + + private Action? OnSnapshotUpdated { get; set; } + + public CustomResourceSnapshot? LastSnapshot { get; set; } + + /// + /// Watch for changes to the dashboard state for a resource. + /// + public IAsyncEnumerable WatchAsync() => new ResourceUpdatesAsyncEnumerable(this); + + /// + /// Updates the snapshot of the for a resource. + /// + /// The new . + public Task PublishUpdateAsync(CustomResourceSnapshot state) + { + if (_streamClosedCts.IsCancellationRequested) + { + return Task.CompletedTask; + } + + OnSnapshotUpdated?.Invoke(state); + + return Task.CompletedTask; + } + + /// + /// Signal that no more updates are expected for this resource. + /// + public void Complete() + { + _streamClosedCts.Cancel(); + } + + private sealed class ResourceUpdatesAsyncEnumerable(ResourceNotificationState customResourceAnnotation) : IAsyncEnumerable + { + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + if (customResourceAnnotation.LastSnapshot is not null) + { + yield return customResourceAnnotation.LastSnapshot; + } + + var channel = Channel.CreateUnbounded(); + + void WriteToChannel(CustomResourceSnapshot state) + => channel.Writer.TryWrite(state); + + using var _ = customResourceAnnotation._streamClosedCts.Token.Register(() => channel.Writer.TryComplete()); + + customResourceAnnotation.OnSnapshotUpdated = WriteToChannel; + + try + { + await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return item; + } + } + finally + { + customResourceAnnotation.OnSnapshotUpdated -= WriteToChannel; + + channel.Writer.TryComplete(); + } + } + } + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationState.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationState.cs new file mode 100644 index 0000000000..0d8b3755cd --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationState.cs @@ -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.ApplicationModel; + +/// +/// An annotation that represents the initial snapshot of a resource. +/// +public class ResourceSnapshotAnnotation(CustomResourceSnapshot initialSnapshot) : IResourceAnnotation +{ + /// + /// The initial snapshot of the resource. + /// + public CustomResourceSnapshot InitialSnapshot { get; } = initialSnapshot; +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs deleted file mode 100644 index 888d79b988..0000000000 --- a/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs +++ /dev/null @@ -1,170 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Immutable; -using System.Threading.Channels; -using Aspire.Dashboard.Model; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// The annotation that allows publishing and subscribing to changes in the state of a resource. -/// -public sealed class ResourceUpdatesAnnotation(Func> initialSnapshotFactory) : IResourceAnnotation -{ - private readonly CancellationTokenSource _streamClosedCts = new(); - - private Action? OnSnapshotUpdated { get; set; } - - /// - /// Watch for changes to the dashboard state for a resource. - /// - public IAsyncEnumerable WatchAsync() => new ResourceUpdatesAsyncEnumerable(this); - - /// - /// Gets the initial snapshot of the dashboard state for this resource. - /// - public ValueTask GetInitialSnapshotAsync(CancellationToken cancellationToken = default) => initialSnapshotFactory(cancellationToken); - - /// - /// Updates the snapshot of the for a resource. - /// - /// The new . - public Task UpdateStateAsync(CustomResourceSnapshot state) - { - if (_streamClosedCts.IsCancellationRequested) - { - return Task.CompletedTask; - } - - OnSnapshotUpdated?.Invoke(state); - - return Task.CompletedTask; - } - - /// - /// Signal that no more updates are expected for this resource. - /// - public void Complete() - { - _streamClosedCts.Cancel(); - } - - private sealed class ResourceUpdatesAsyncEnumerable(ResourceUpdatesAnnotation customResourceAnnotation) : IAsyncEnumerable - { - public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - var channel = Channel.CreateUnbounded(); - - void WriteToChannel(CustomResourceSnapshot state) - => channel.Writer.TryWrite(state); - - using var _ = customResourceAnnotation._streamClosedCts.Token.Register(() => channel.Writer.TryComplete()); - - customResourceAnnotation.OnSnapshotUpdated = WriteToChannel; - - try - { - await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) - { - yield return item; - } - } - finally - { - customResourceAnnotation.OnSnapshotUpdated -= WriteToChannel; - - channel.Writer.TryComplete(); - } - } - } -} - -/// -/// An immutable snapshot of the state of a resource. -/// -public sealed record CustomResourceSnapshot -{ - /// - /// The type of the resource. - /// - public required string ResourceType { get; init; } - - /// - /// The properties that should show up in the dashboard for this resource. - /// - public required ImmutableArray<(string Key, string Value)> Properties { get; init; } - - /// - /// Represents the state of the resource. - /// - public string? State { get; init; } - - /// - /// The environment variables that should show up in the dashboard for this resource. - /// - public ImmutableArray<(string Name, string Value)> EnvironmentVariables { get; init; } = []; - - /// - /// The URLs that should show up in the dashboard for this resource. - /// - public ImmutableArray Urls { get; init; } = []; - - /// - /// Creates a new for a resource using the well known annotations. - /// - /// The resource. - /// The cancellation token. - /// The new . - public static async ValueTask CreateAsync(IResource resource, CancellationToken cancellationToken = default) - { - ImmutableArray urls = []; - - if (resource.TryGetAnnotationsOfType(out var endpointAnnotations)) - { - static string GetUrl(EndpointAnnotation e) => - $"{e.UriScheme}://localhost:{e.Port}"; - - urls = [.. endpointAnnotations.Where(e => e.Port is not null).Select(e => GetUrl(e))]; - } - - ImmutableArray<(string, string)> environmentVariables = []; - - if (resource.TryGetAnnotationsOfType(out var environmentCallbacks)) - { - var envContext = new EnvironmentCallbackContext(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), cancellationToken: cancellationToken); - foreach (var annotation in environmentCallbacks) - { - await annotation.Callback(envContext).ConfigureAwait(false); - } - - environmentVariables = [.. envContext.EnvironmentVariables.Select(e => (e.Key, e.Value))]; - } - - ImmutableArray<(string, string)> properties = []; - if (resource is IResourceWithConnectionString connectionStringResource) - { - properties = [("ConnectionString", connectionStringResource.GetConnectionString() ?? "")]; - } - - // Initialize the state with the well known annotations - return new CustomResourceSnapshot() - { - ResourceType = resource.GetType().Name.Replace("Resource", ""), - EnvironmentVariables = environmentVariables, - Urls = urls, - Properties = properties - }; - } -} - -/// -/// Known properties for resources that show up in the dashboard. -/// -public static class CustomResourceKnownProperties -{ - /// - /// The source of the resource - /// - public static string Source { get; } = KnownProperties.Resource.Source; -} diff --git a/src/Aspire.Hosting/Dashboard/ConsoleLogPublisher.cs b/src/Aspire.Hosting/Dashboard/ConsoleLogPublisher.cs index 119da9b2a4..2016b667f7 100644 --- a/src/Aspire.Hosting/Dashboard/ConsoleLogPublisher.cs +++ b/src/Aspire.Hosting/Dashboard/ConsoleLogPublisher.cs @@ -13,7 +13,7 @@ namespace Aspire.Hosting.Dashboard; internal sealed class ConsoleLogPublisher( ResourcePublisher resourcePublisher, - IReadOnlyDictionary resourceMap, + ResourceLoggerService resourceLoggerService, IKubernetesService kubernetesService, ILoggerFactory loggerFactory, IConfiguration configuration) @@ -33,7 +33,7 @@ internal sealed class ConsoleLogPublisher( { ExecutableSnapshot executable => SubscribeExecutableResource(executable), ContainerSnapshot container => SubscribeContainerResource(container), - GenericResourceSnapshot genericResource when resourceMap.TryGetValue(genericResource.Name, out var appModelResource) => SubscribeGenericResource(appModelResource), + GenericResourceSnapshot genericResource => resourceLoggerService.WatchAsync(genericResource.Name), _ => throw new NotSupportedException($"Unsupported resource type {resource.GetType()}.") }; } @@ -43,7 +43,7 @@ GenericResourceSnapshot genericResource when resourceMap.TryGetValue(genericReso { ExecutableSnapshot executable => SubscribeExecutable(executable), ContainerSnapshot container => SubscribeContainer(container), - GenericResourceSnapshot genericResource when resourceMap.TryGetValue(genericResource.Name, out var appModelResource) => SubscribeGenericResource(appModelResource), + GenericResourceSnapshot genericResource => resourceLoggerService.WatchAsync(genericResource.Name), _ => throw new NotSupportedException($"Unsupported resource type {resource.GetType()}.") }; } @@ -80,21 +80,4 @@ LogsEnumerable SubscribeContainerResource(ContainerSnapshot container) return new DockerContainerLogSource(container.ContainerId); } } - - private static LogsEnumerable SubscribeGenericResource(IResource resource) - { - if (resource.TryGetLastAnnotation(out var loggerAnnotation)) - { - return loggerAnnotation.WatchAsync(); - } - - return NoLogsAvailableEnumerable(); - } - -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - private static async LogsEnumerable NoLogsAvailableEnumerable() -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - { - yield return [("No logs available", false)]; - } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 4a0950b3c5..6761ba9994 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -22,15 +22,17 @@ internal sealed class DashboardServiceData : IAsyncDisposable public DashboardServiceData( DistributedApplicationModel applicationModel, IKubernetesService kubernetesService, + ResourceNotificationService resourceNotificationService, + ResourceLoggerService resourceLoggerService, IConfiguration configuration, ILoggerFactory loggerFactory) { var resourceMap = applicationModel.Resources.ToDictionary(resource => resource.Name, StringComparer.Ordinal); _resourcePublisher = new ResourcePublisher(_cts.Token); - _consoleLogPublisher = new ConsoleLogPublisher(_resourcePublisher, resourceMap, kubernetesService, loggerFactory, configuration); + _consoleLogPublisher = new ConsoleLogPublisher(_resourcePublisher, resourceLoggerService, kubernetesService, loggerFactory, configuration); - _ = new DcpDataSource(kubernetesService, resourceMap, configuration, loggerFactory, _resourcePublisher.IntegrateAsync, _cts.Token); + _ = new DcpDataSource(kubernetesService, resourceNotificationService, resourceMap, configuration, loggerFactory, _resourcePublisher.IntegrateAsync, _cts.Token); } public async ValueTask DisposeAsync() diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index d9570351d4..1e246c4f9c 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -52,7 +52,9 @@ public DashboardServiceHost( IConfiguration configuration, DistributedApplicationExecutionContext executionContext, ILoggerFactory loggerFactory, - IConfigureOptions loggerOptions) + IConfigureOptions loggerOptions, + ResourceNotificationService resourceNotificationService, + ResourceLoggerService resourceLoggerService) { _logger = loggerFactory.CreateLogger(); @@ -82,6 +84,8 @@ public DashboardServiceHost( builder.Services.AddSingleton(applicationModel); builder.Services.AddSingleton(kubernetesService); builder.Services.AddSingleton(); + builder.Services.AddSingleton(resourceNotificationService); + builder.Services.AddSingleton(resourceLoggerService); builder.WebHost.ConfigureKestrel(ConfigureKestrel); diff --git a/src/Aspire.Hosting/Dashboard/DcpDataSource.cs b/src/Aspire.Hosting/Dashboard/DcpDataSource.cs index b436e4a19a..0ea962130a 100644 --- a/src/Aspire.Hosting/Dashboard/DcpDataSource.cs +++ b/src/Aspire.Hosting/Dashboard/DcpDataSource.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Text.Json; +using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -23,6 +24,7 @@ namespace Aspire.Hosting.Dashboard; internal sealed class DcpDataSource { private readonly IKubernetesService _kubernetesService; + private readonly ResourceNotificationService _notificationService; private readonly IReadOnlyDictionary _applicationModel; private readonly ConcurrentDictionary _placeHolderResources = []; private readonly Func _onResourceChanged; @@ -36,14 +38,16 @@ internal sealed class DcpDataSource public DcpDataSource( IKubernetesService kubernetesService, - IReadOnlyDictionary applicationModel, + ResourceNotificationService notificationService, + IReadOnlyDictionary applicationModelMap, IConfiguration configuration, ILoggerFactory loggerFactory, Func onResourceChanged, CancellationToken cancellationToken) { _kubernetesService = kubernetesService; - _applicationModel = applicationModel; + _notificationService = notificationService; + _applicationModel = applicationModelMap; _onResourceChanged = onResourceChanged; _logger = loggerFactory.CreateLogger(); @@ -56,6 +60,12 @@ public DcpDataSource( // Show all resources initially and allow updates from DCP (for the relevant resources) foreach (var (_, resource) in _applicationModel) { + if (resource.Name == KnownResourceNames.AspireDashboard && + configuration.GetBool("DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES") is not true) + { + continue; + } + await ProcessInitialResourceAsync(resource, cancellationToken).ConfigureAwait(false); } @@ -111,143 +121,86 @@ bool IsFilteredResource(T resource) where T : CustomResource } } - private async Task ProcessInitialResourceAsync(IResource resource, CancellationToken cancellationToken) + private Task ProcessInitialResourceAsync(IResource resource, CancellationToken cancellationToken) { - // If the resource is a redirect, we want to create a snapshot for the resource it redirects to. - if (resource.TryGetLastAnnotation(out var redirectAnnotation)) - { - resource = redirectAnnotation.Resource; - } - - if (resource.TryGetLastAnnotation(out var containerImageAnnotation)) + // The initial snapshots are all generic resources until we get the real state from DCP (for projects, containers and executables). + if (resource.IsContainer()) { - var snapshot = new ContainerSnapshot + var snapshot = CreateResourceSnapshot(resource, DateTime.UtcNow, new CustomResourceSnapshot { - Name = resource.Name, - DisplayName = resource.Name, - Uid = resource.Name, - CreationTimeStamp = DateTime.UtcNow, - Image = containerImageAnnotation.Image, - State = "Starting", - ContainerId = null, - ExitCode = null, - ExpectedEndpointsCount = null, - Environment = [], - Endpoints = [], - Services = [], - Command = null, - Args = [], - Ports = [] - }; + ResourceType = KnownResourceTypes.Container, + Properties = [], + }); _placeHolderResources.TryAdd(resource.Name, snapshot); - - await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); } - else if (resource is ProjectResource p) + else if (resource is ProjectResource) { - var snapshot = new ProjectSnapshot + var snapshot = CreateResourceSnapshot(resource, DateTime.UtcNow, new CustomResourceSnapshot { - Name = p.Name, - DisplayName = p.Name, - Uid = p.Name, - CreationTimeStamp = DateTime.UtcNow, - ProjectPath = p.GetProjectMetadata().ProjectPath, - State = "Starting", - ExpectedEndpointsCount = null, - Environment = [], - Endpoints = [], - Services = [], - ExecutablePath = null, - ExitCode = null, - Arguments = null, - ProcessId = null, - StdErrFile = null, - StdOutFile = null, - WorkingDirectory = null - }; + ResourceType = KnownResourceTypes.Project, + Properties = [], + }); _placeHolderResources.TryAdd(resource.Name, snapshot); - - await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); } - else if (resource is ExecutableResource exe) + else if (resource is ExecutableResource) { - var snapshot = new ExecutableSnapshot + var snapshot = CreateResourceSnapshot(resource, DateTime.UtcNow, new CustomResourceSnapshot { - Name = exe.Name, - DisplayName = exe.Name, - Uid = exe.Name, - CreationTimeStamp = DateTime.UtcNow, - ExecutablePath = exe.Command, - WorkingDirectory = exe.WorkingDirectory, - Arguments = null, - State = "Starting", - ExitCode = null, - StdOutFile = null, - StdErrFile = null, - ProcessId = null, - ExpectedEndpointsCount = null, - Environment = [], - Endpoints = [], - Services = [] - }; + ResourceType = KnownResourceTypes.Executable, + Properties = [], + }); _placeHolderResources.TryAdd(resource.Name, snapshot); - - await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); } - else if (resource.TryGetLastAnnotation(out var resourceUpdates)) - { - // We have a dashboard annotation, so we want to create a snapshot for the resource - // and update data immediately. We also want to watch for changes to the dashboard state. - var state = await resourceUpdates.GetInitialSnapshotAsync(cancellationToken).ConfigureAwait(false); - var creationTimestamp = DateTime.UtcNow; - - var snapshot = CreateResourceSnapshot(resource, creationTimestamp, state); - await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); + var creationTimestamp = DateTime.UtcNow; - _ = Task.Run(async () => + _ = Task.Run(async () => + { + await foreach (var state in _notificationService.WatchAsync(resource).WithCancellation(cancellationToken)) { - await foreach (var state in resourceUpdates.WatchAsync().WithCancellation(cancellationToken)) + try { - try - { - var snapshot = CreateResourceSnapshot(resource, creationTimestamp, state); + var snapshot = CreateResourceSnapshot(resource, creationTimestamp, state); - await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, "Error updating resource snapshot for {Name}", resource.Name); - } + await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Error updating resource snapshot for {Name}", resource.Name); } + } - }, cancellationToken); - } + }, cancellationToken); + + return Task.CompletedTask; } - private static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, DateTime creationTimestamp, CustomResourceSnapshot dashboardState) + private static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, DateTime creationTimestamp, CustomResourceSnapshot snapshot) { ImmutableArray environmentVariables = [.. - dashboardState.EnvironmentVariables.Select(e => new EnvironmentVariableSnapshot(e.Name, e.Value, false))]; + snapshot.EnvironmentVariables.Select(e => new EnvironmentVariableSnapshot(e.Name, e.Value, false))]; + + ImmutableArray services = [.. + snapshot.Urls.Select(u => new ResourceServiceSnapshot(u.Name, u.Url, null))]; ImmutableArray endpoints = [.. - dashboardState.Urls.Select(u => new EndpointSnapshot(u, u))]; + snapshot.Urls.Select(u => new EndpointSnapshot(u.Url, u.Url))]; - return new GenericResourceSnapshot(dashboardState) + return new GenericResourceSnapshot(snapshot) { Uid = resource.Name, - CreationTimeStamp = creationTimestamp, + CreationTimeStamp = snapshot.CreationTimeStamp ?? creationTimestamp, Name = resource.Name, DisplayName = resource.Name, Endpoints = endpoints, Environment = environmentVariables, ExitCode = null, ExpectedEndpointsCount = endpoints.Length, - Services = [], - State = dashboardState.State ?? "Running" + Services = services, + State = snapshot.State ?? "Running" }; } @@ -264,10 +217,6 @@ private async Task ProcessResourceChange(WatchEventType watchEventType, T res _ => throw new System.ComponentModel.InvalidEnumArgumentException($"Cannot convert {nameof(WatchEventType)} with value {watchEventType} into enum of type {nameof(ResourceSnapshotChangeType)}.") }; - var snapshot = snapshotFactory(resource); - - await _onResourceChanged(snapshot, changeType).ConfigureAwait(false); - // Remove the placeholder resource if it exists since we're getting an update about the real resource // from DCP. string? resourceName = null; @@ -277,6 +226,10 @@ private async Task ProcessResourceChange(WatchEventType watchEventType, T res { await _onResourceChanged(placeHolder, ResourceSnapshotChangeType.Delete).ConfigureAwait(false); } + + var snapshot = snapshotFactory(resource); + + await _onResourceChanged(snapshot, changeType).ConfigureAwait(false); } void UpdateAssociatedServicesMap() diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index a4b02c2d25..dc6ad6e150 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; +using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Lifecycle; @@ -59,7 +60,9 @@ internal sealed class ApplicationExecutor(ILogger logger, IOptions options, IDashboardEndpointProvider dashboardEndpointProvider, IDashboardAvailability dashboardAvailability, - DistributedApplicationExecutionContext executionContext) + DistributedApplicationExecutionContext executionContext, + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) { private const string DebugSessionPortVar = "DEBUG_SESSION_PORT"; @@ -305,8 +308,10 @@ private async Task CreateContainersAndExecutablesAsync(CancellationToken cancell await lifecycleHook.AfterEndpointsAllocatedAsync(_model, cancellationToken).ConfigureAwait(false); } - await CreateContainersAsync(toCreate.Where(ar => ar.DcpResource is Container), cancellationToken).ConfigureAwait(false); - await CreateExecutablesAsync(toCreate.Where(ar => ar.DcpResource is Executable || ar.DcpResource is ExecutableReplicaSet), cancellationToken).ConfigureAwait(false); + var containersTask = CreateContainersAsync(toCreate.Where(ar => ar.DcpResource is Container), cancellationToken); + var executablesTask = CreateExecutablesAsync(toCreate.Where(ar => ar.DcpResource is Executable || ar.DcpResource is ExecutableReplicaSet), cancellationToken); + + await Task.WhenAll(containersTask, executablesTask).ConfigureAwait(false); } private static void AddAllocatedEndpointInfo(IEnumerable resources) @@ -496,7 +501,7 @@ private void PrepareProjectExecutables() } } - private async Task CreateExecutablesAsync(IEnumerable executableResources, CancellationToken cancellationToken) + private Task CreateExecutablesAsync(IEnumerable executableResources, CancellationToken cancellationToken) { try { @@ -513,103 +518,136 @@ private async Task CreateExecutablesAsync(IEnumerable executableRes sortedExecutableResources.Insert(0, dashboardAppResource); } - foreach (var er in sortedExecutableResources) + async Task CreateExecutableAsyncCore(AppResource cr, CancellationToken cancellationToken) { - ExecutableSpec spec; - Func> createResource; + var logger = loggerService.GetLogger(cr.ModelResource); - switch (er.DcpResource) + await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with { - case Executable exe: - spec = exe.Spec; - createResource = async () => await kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false); - break; - case ExecutableReplicaSet ers: - spec = ers.Spec.Template.Spec; - createResource = async () => await kubernetesService.CreateAsync(ers, cancellationToken).ConfigureAwait(false); - break; - default: - throw new InvalidOperationException($"Expected an Executable-like resource, but got {er.DcpResource.Kind} instead"); - } - - spec.Args ??= new(); + ResourceType = cr.ModelResource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable, + Properties = [], + State = "Starting" + }) + .ConfigureAwait(false); - if (er.ModelResource.TryGetAnnotationsOfType(out var exeArgsCallbacks)) + try { - var commandLineContext = new CommandLineArgsCallbackContext(spec.Args, cancellationToken); + await CreateExecutableAsync(cr, logger, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create resource {ResourceName}", cr.ModelResource.Name); - foreach (var exeArgsCallback in exeArgsCallbacks) - { - await exeArgsCallback.Callback(commandLineContext).ConfigureAwait(false); - } + await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with { State = "FailedToStart" }).ConfigureAwait(false); } + } - var config = new Dictionary(); - var context = new EnvironmentCallbackContext(_executionContext, config, cancellationToken); + var tasks = new List(); + foreach (var er in sortedExecutableResources) + { + tasks.Add(CreateExecutableAsyncCore(er, cancellationToken)); + } - // Need to apply configuration settings manually; see PrepareExecutables() for details. - if (er.ModelResource is ProjectResource project && project.SelectLaunchProfileName() is { } launchProfileName && project.GetLaunchSettings() is { } launchSettings) - { - ApplyLaunchProfile(er, config, launchProfileName, launchSettings); - } - else - { - if (er.ServicesProduced.Count > 0) - { - if (er.ModelResource is ProjectResource) - { - var urls = er.ServicesProduced.Where(IsUnspecifiedHttpService).Select(sar => - { - var url = sar.EndpointAnnotation.UriScheme + "://localhost:{{- portForServing \"" + sar.Service.Metadata.Name + "\" -}}"; - return url; - }); - - // REVIEW: Should we assume ASP.NET Core? - // We're going to use http and https urls as ASPNETCORE_URLS - config["ASPNETCORE_URLS"] = string.Join(";", urls); - } + return Task.WhenAll(tasks); + } + finally + { + AspireEventSource.Instance.DcpExecutablesCreateStop(); + } + } - InjectPortEnvVars(er, config); - } - } + private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, CancellationToken cancellationToken) + { + ExecutableSpec spec; + Func> createResource; - if (er.ModelResource.TryGetEnvironmentVariables(out var envVarAnnotations)) - { - foreach (var ann in envVarAnnotations) - { - await ann.Callback(context).ConfigureAwait(false); - } - } + switch (er.DcpResource) + { + case Executable exe: + spec = exe.Spec; + createResource = async () => await kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false); + break; + case ExecutableReplicaSet ers: + spec = ers.Spec.Template.Spec; + createResource = async () => await kubernetesService.CreateAsync(ers, cancellationToken).ConfigureAwait(false); + break; + default: + throw new InvalidOperationException($"Expected an Executable-like resource, but got {er.DcpResource.Kind} instead"); + } - spec.Env = new(); - foreach (var c in config) - { - spec.Env.Add(new EnvVar { Name = c.Key, Value = c.Value }); - } + spec.Args ??= []; - await createResource().ConfigureAwait(false); + if (er.ModelResource.TryGetAnnotationsOfType(out var exeArgsCallbacks)) + { + var commandLineContext = new CommandLineArgsCallbackContext(spec.Args, cancellationToken); - // NOTE: This check is only necessary for the inner loop in the dotnet/aspire repo. When - // running in the dotnet/aspire repo we will normally launch the dashboard via - // AddProject. When doing this we make sure that the dashboard is running. - if (!distributedApplicationOptions.DisableDashboard && er.ModelResource.Name.Equals(KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) + foreach (var exeArgsCallback in exeArgsCallbacks) + { + await exeArgsCallback.Callback(commandLineContext).ConfigureAwait(false); + } + } + + var config = new Dictionary(); + var context = new EnvironmentCallbackContext(_executionContext, config, cancellationToken) + { + Logger = resourceLogger + }; + + // Need to apply configuration settings manually; see PrepareExecutables() for details. + if (er.ModelResource is ProjectResource project && project.SelectLaunchProfileName() is { } launchProfileName && project.GetLaunchSettings() is { } launchSettings) + { + ApplyLaunchProfile(er, config, launchProfileName, launchSettings); + } + else + { + if (er.ServicesProduced.Count > 0) + { + if (er.ModelResource is ProjectResource) { - // We just check the HTTP endpoint because this will prove that the - // dashboard is listening and is ready to process requests. - if (configuration["ASPNETCORE_URLS"] is not { } dashboardUrls) + var urls = er.ServicesProduced.Where(IsUnspecifiedHttpService).Select(sar => { - throw new DistributedApplicationException("Cannot check dashboard availability since ASPNETCORE_URLS environment variable not set."); - } + var url = sar.EndpointAnnotation.UriScheme + "://localhost:{{- portForServing \"" + sar.Service.Metadata.Name + "\" -}}"; + return url; + }); - await CheckDashboardAvailabilityAsync(dashboardUrls, cancellationToken).ConfigureAwait(false); + // REVIEW: Should we assume ASP.NET Core? + // We're going to use http and https urls as ASPNETCORE_URLS + config["ASPNETCORE_URLS"] = string.Join(";", urls); } + InjectPortEnvVars(er, config); } + } + if (er.ModelResource.TryGetEnvironmentVariables(out var envVarAnnotations)) + { + foreach (var ann in envVarAnnotations) + { + await ann.Callback(context).ConfigureAwait(false); + } } - finally + + spec.Env = []; + foreach (var c in config) { - AspireEventSource.Instance.DcpExecutablesCreateStop(); + spec.Env.Add(new EnvVar { Name = c.Key, Value = c.Value }); + } + + await createResource().ConfigureAwait(false); + + // NOTE: This check is only necessary for the inner loop in the dotnet/aspire repo. When + // running in the dotnet/aspire repo we will normally launch the dashboard via + // AddProject. When doing this we make sure that the dashboard is running. + if (!distributedApplicationOptions.DisableDashboard && er.ModelResource.Name.Equals(KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) + { + // We just check the HTTP endpoint because this will prove that the + // dashboard is listening and is ready to process requests. + if (configuration["ASPNETCORE_URLS"] is not { } dashboardUrls) + { + throw new DistributedApplicationException("Cannot check dashboard availability since ASPNETCORE_URLS environment variable not set."); + } + + await CheckDashboardAvailabilityAsync(dashboardUrls, cancellationToken).ConfigureAwait(false); } } @@ -737,102 +775,134 @@ private void PrepareContainers() } } - private async Task CreateContainersAsync(IEnumerable containerResources, CancellationToken cancellationToken) + private Task CreateContainersAsync(IEnumerable containerResources, CancellationToken cancellationToken) { try { AspireEventSource.Instance.DcpContainersCreateStart(); - foreach (var cr in containerResources) + async Task CreateContainerAsyncCore(AppResource cr, CancellationToken cancellationToken) { - var dcpContainerResource = (Container)cr.DcpResource; - var modelContainerResource = cr.ModelResource; + var logger = loggerService.GetLogger(cr.ModelResource); - var config = new Dictionary(); - - dcpContainerResource.Spec.Env = new(); + await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with + { + State = "Starting", + Properties = [], // TODO: Add image name + ResourceType = KnownResourceTypes.Container + }) + .ConfigureAwait(false); - if (cr.ServicesProduced.Count > 0) + try + { + await CreateContainerAsync(cr, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) { - dcpContainerResource.Spec.Ports = new(); + logger.LogError(ex, "Failed to create container resource {ResourceName}", cr.ModelResource.Name); - foreach (var sp in cr.ServicesProduced) - { - var portSpec = new ContainerPortSpec() - { - ContainerPort = sp.DcpServiceProducerAnnotation.Port, - }; + await notificationService.PublishUpdateAsync(cr.ModelResource, s => s with { State = "FailedToStart" }).ConfigureAwait(false); + } + } - if (!sp.EndpointAnnotation.IsProxied) - { - // When DCP isn't proxying the container we need to set the host port that the containers internal port will be mapped to - portSpec.HostPort = sp.EndpointAnnotation.Port; - } + var tasks = new List(); + foreach (var cr in containerResources) + { + tasks.Add(CreateContainerAsyncCore(cr, cancellationToken)); + } - if (!string.IsNullOrEmpty(sp.DcpServiceProducerAnnotation.Address)) - { - portSpec.HostIP = sp.DcpServiceProducerAnnotation.Address; - } + return Task.WhenAll(tasks); + } + finally + { + AspireEventSource.Instance.DcpContainersCreateStop(); + } + } - switch (sp.EndpointAnnotation.Protocol) - { - case ProtocolType.Tcp: - portSpec.Protocol = PortProtocol.TCP; break; - case ProtocolType.Udp: - portSpec.Protocol = PortProtocol.UDP; break; - } + private async Task CreateContainerAsync(AppResource cr, CancellationToken cancellationToken) + { + var dcpContainerResource = (Container)cr.DcpResource; + var modelContainerResource = cr.ModelResource; - dcpContainerResource.Spec.Ports.Add(portSpec); + var config = new Dictionary(); - var name = sp.Service.Metadata.Name; - var envVar = sp.EndpointAnnotation.EnvironmentVariable; + dcpContainerResource.Spec.Env = []; - if (envVar is not null) - { - config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}"); - } - } - } + if (cr.ServicesProduced.Count > 0) + { + dcpContainerResource.Spec.Ports = new(); - if (modelContainerResource.TryGetEnvironmentVariables(out var containerEnvironmentVariables)) + foreach (var sp in cr.ServicesProduced) + { + var portSpec = new ContainerPortSpec() { - var context = new EnvironmentCallbackContext(_executionContext, config, cancellationToken); + ContainerPort = sp.DcpServiceProducerAnnotation.Port, + }; - foreach (var v in containerEnvironmentVariables) - { - await v.Callback(context).ConfigureAwait(false); - } + if (!sp.EndpointAnnotation.IsProxied) + { + // When DCP isn't proxying the container we need to set the host port that the containers internal port will be mapped to + portSpec.HostPort = sp.EndpointAnnotation.Port; } - foreach (var kvp in config) + if (!string.IsNullOrEmpty(sp.DcpServiceProducerAnnotation.Address)) { - dcpContainerResource.Spec.Env.Add(new EnvVar { Name = kvp.Key, Value = kvp.Value }); + portSpec.HostIP = sp.DcpServiceProducerAnnotation.Address; } - if (modelContainerResource.TryGetAnnotationsOfType(out var argsCallback)) + switch (sp.EndpointAnnotation.Protocol) { - dcpContainerResource.Spec.Args ??= []; + case ProtocolType.Tcp: + portSpec.Protocol = PortProtocol.TCP; break; + case ProtocolType.Udp: + portSpec.Protocol = PortProtocol.UDP; break; + } - var commandLineArgsContext = new CommandLineArgsCallbackContext(dcpContainerResource.Spec.Args, cancellationToken); + dcpContainerResource.Spec.Ports.Add(portSpec); - foreach (var callback in argsCallback) - { - await callback.Callback(commandLineArgsContext).ConfigureAwait(false); - } - } + var name = sp.Service.Metadata.Name; + var envVar = sp.EndpointAnnotation.EnvironmentVariable; - if (modelContainerResource is ContainerResource containerResource) + if (envVar is not null) { - dcpContainerResource.Spec.Command = containerResource.Entrypoint; + config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}"); } + } + } + + if (modelContainerResource.TryGetEnvironmentVariables(out var containerEnvironmentVariables)) + { + var context = new EnvironmentCallbackContext(_executionContext, config, cancellationToken); - await kubernetesService.CreateAsync(dcpContainerResource, cancellationToken).ConfigureAwait(false); + foreach (var v in containerEnvironmentVariables) + { + await v.Callback(context).ConfigureAwait(false); } } - finally + + foreach (var kvp in config) { - AspireEventSource.Instance.DcpContainersCreateStop(); + dcpContainerResource.Spec.Env.Add(new EnvVar { Name = kvp.Key, Value = kvp.Value }); } + + if (modelContainerResource.TryGetAnnotationsOfType(out var argsCallback)) + { + dcpContainerResource.Spec.Args ??= []; + + var commandLineArgsContext = new CommandLineArgsCallbackContext(dcpContainerResource.Spec.Args, cancellationToken); + + foreach (var callback in argsCallback) + { + await callback.Callback(commandLineArgsContext).ConfigureAwait(false); + } + } + + if (modelContainerResource is ContainerResource containerResource) + { + dcpContainerResource.Spec.Command = containerResource.Entrypoint; + } + + await kubernetesService.CreateAsync(dcpContainerResource, cancellationToken).ConfigureAwait(false); } private void AddServicesProducedInfo(IResource modelResource, IAnnotationHolder dcpResource, AppResource appResource) diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 3134a792f0..d96974acf9 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -80,6 +80,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddSingleton(options); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); // Dashboard _innerBuilder.Services.AddSingleton(); diff --git a/src/Aspire.Hosting/Extensions/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/Extensions/ParameterResourceBuilderExtensions.cs index 186527b8d6..510d67e8d4 100644 --- a/src/Aspire.Hosting/Extensions/ParameterResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/Extensions/ParameterResourceBuilderExtensions.cs @@ -36,27 +36,27 @@ internal static IResourceBuilder AddParameter(this IDistribut bool connectionString = false) { var resource = new ParameterResource(name, callback, secret); - return builder.AddResource(resource) - .WithResourceUpdates(() => - { - var state = new CustomResourceSnapshot() - { - ResourceType = "Parameter", - Properties = [ - ("Secret", secret.ToString()), - (CustomResourceKnownProperties.Source, connectionString ? $"ConnectionStrings:{name}" : $"Parameters:{name}") - ] - }; - try - { - return state with { Properties = [.. state.Properties, ("Value", callback())] }; - } - catch (DistributedApplicationException ex) - { - return state with { State = "FailedToStart", Properties = [.. state.Properties, ("Value", ex.Message)] }; - } - }) + var state = new CustomResourceSnapshot() + { + ResourceType = "Parameter", + Properties = [ + ("parameter.secret", secret.ToString()), + (CustomResourceKnownProperties.Source, connectionString ? $"ConnectionStrings:{name}" : $"Parameters:{name}") + ] + }; + + try + { + state = state with { Properties = [.. state.Properties, ("Value", callback())] }; + } + catch (DistributedApplicationException ex) + { + state = state with { State = "FailedToStart", Properties = [.. state.Properties, ("Value", ex.Message)] }; + } + + return builder.AddResource(resource) + .WithInitialState(state) .WithManifestPublishingCallback(context => WriteParameterResourceToManifest(context, resource, connectionString)); } diff --git a/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs index ee86aee8f6..804bc022dd 100644 --- a/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -228,6 +229,8 @@ public static IResourceBuilder WithReference(this IR return; } + context.Logger?.LogInformation("Retrieving connection string for '{Name}'.", resource.Name); + var connectionString = await resource.GetConnectionStringAsync(context.CancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(connectionString)) diff --git a/src/Aspire.Hosting/Postgres/PostgresDatabaseResource.cs b/src/Aspire.Hosting/Postgres/PostgresDatabaseResource.cs index 9bd058bd79..a621be72ba 100644 --- a/src/Aspire.Hosting/Postgres/PostgresDatabaseResource.cs +++ b/src/Aspire.Hosting/Postgres/PostgresDatabaseResource.cs @@ -39,6 +39,23 @@ public class PostgresDatabaseResource(string name, string databaseName, Postgres } } + /// + /// Gets the connection string for the Postgres database. + /// + /// A to observe while waiting for the task to complete. + /// A connection string for the Postgres database. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (await Parent.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false) is { } connectionString) + { + return $"{connectionString};Database={DatabaseName}"; + } + else + { + throw new DistributedApplicationException("Parent resource connection string was null."); + } + } + /// /// Gets the database name. /// diff --git a/src/Aspire.Hosting/SqlServer/SqlServerDatabaseResource.cs b/src/Aspire.Hosting/SqlServer/SqlServerDatabaseResource.cs index 68ce6be7c3..b91c8aaed9 100644 --- a/src/Aspire.Hosting/SqlServer/SqlServerDatabaseResource.cs +++ b/src/Aspire.Hosting/SqlServer/SqlServerDatabaseResource.cs @@ -40,6 +40,23 @@ public class SqlServerDatabaseResource(string name, string databaseName, SqlServ } } + /// + /// Gets the connection string for the database resource. + /// + /// The connection string for the database resource. + /// Thrown when the parent resource connection string is null. + public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (await Parent.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false) is { } connectionString) + { + return $"{connectionString};Database={DatabaseName}"; + } + else + { + throw new DistributedApplicationException("Parent resource connection string was null."); + } + } + /// /// Gets the database name. /// diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 75917d5230..9ea5b5ec85 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -22,6 +22,7 @@ public static class Resource public const string ExitCode = "resource.exitCode"; public const string CreateTime = "resource.createTime"; public const string Source = "resource.source"; + public const string ConnectionString = "resource.connectionString"; } public static class Container diff --git a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs index 360444cca9..2306434a1e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs @@ -72,7 +72,9 @@ private static ApplicationExecutor CreateAppExecutor( }), new MockDashboardEndpointProvider(), new MockDashboardAvailability(), - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run) + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + new ResourceNotificationService(), + new ResourceLoggerService() ); } } diff --git a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs new file mode 100644 index 0000000000..dfe2fd1d6b --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Aspire.Hosting.Tests; + +public class ResourceLoggerServiceTests +{ + [Fact] + public void AddingResourceLoggerAnnotationAllowsLogging() + { + var testResource = new TestResource("myResource"); + + var service = new ResourceLoggerService(); + + var logger = service.GetLogger(testResource); + + var enumerator = service.WatchAsync(testResource).GetAsyncEnumerator(); + + logger.LogInformation("Hello, world!"); + logger.LogError("Hello, error!"); + service.Complete(testResource); + + var allLogs = service.WatchAsync(testResource).ToBlockingEnumerable().SelectMany(x => x).ToList(); + + Assert.Equal("Hello, world!", allLogs[0].Content); + Assert.False(allLogs[0].IsErrorMessage); + + Assert.Equal("Hello, error!", allLogs[1].Content); + Assert.True(allLogs[1].IsErrorMessage); + + var backlog = service.WatchAsync(testResource).ToBlockingEnumerable().SelectMany(x => x).ToList(); + + Assert.Equal("Hello, world!", backlog[0].Content); + Assert.Equal("Hello, error!", backlog[1].Content); + } + + [Fact] + public async Task StreamingLogsCancelledAfterComplete() + { + var service = new ResourceLoggerService(); + + var testResource = new TestResource("myResource"); + + var logger = service.GetLogger(testResource); + + logger.LogInformation("Hello, world!"); + logger.LogError("Hello, error!"); + service.Complete(testResource); + logger.LogInformation("Hello, again!"); + + var allLogs = service.WatchAsync(testResource).ToBlockingEnumerable().SelectMany(x => x).ToList(); + + Assert.Collection(allLogs, + log => Assert.Equal("Hello, world!", log.Content), + log => Assert.Equal("Hello, error!", log.Content)); + + Assert.DoesNotContain("Hello, again!", allLogs.Select(x => x.Content)); + + await using var backlogEnumerator = service.WatchAsync(testResource).GetAsyncEnumerator(); + Assert.True(await backlogEnumerator.MoveNextAsync()); + Assert.Equal("Hello, world!", backlogEnumerator.Current[0].Content); + Assert.Equal("Hello, error!", backlogEnumerator.Current[1].Content); + + // We're done + Assert.False(await backlogEnumerator.MoveNextAsync()); + } + + private sealed class TestResource(string name) : Resource(name) + { + + } +} diff --git a/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs b/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs deleted file mode 100644 index 628bc92cd0..0000000000 --- a/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Aspire.Hosting.Tests; - -public class ResourceLoggerTests -{ - [Fact] - public void AddingResourceLoggerAnnotationAllowsLogging() - { - var builder = DistributedApplication.CreateBuilder(); - - var testResource = builder.AddResource(new TestResource("myResource")) - .WithResourceLogger(); - - var annotation = testResource.Resource.Annotations.OfType().SingleOrDefault(); - - Assert.NotNull(annotation); - - var enumerator = annotation.WatchAsync().GetAsyncEnumerator(); - - annotation.Logger.LogInformation("Hello, world!"); - annotation.Logger.LogError("Hello, error!"); - annotation.Complete(); - - var allLogs = annotation.WatchAsync().ToBlockingEnumerable().SelectMany(x => x).ToList(); - - Assert.Equal("Hello, world!", allLogs[0].Content); - Assert.False(allLogs[0].IsErrorMessage); - - Assert.Equal("Hello, error!", allLogs[1].Content); - Assert.True(allLogs[1].IsErrorMessage); - - var backlog = annotation.WatchAsync().ToBlockingEnumerable().SelectMany(x => x).ToList(); - - Assert.Equal("Hello, world!", backlog[0].Content); - Assert.Equal("Hello, error!", backlog[1].Content); - } - - [Fact] - public async Task StreamingLogsCancelledAfterComplete() - { - var builder = DistributedApplication.CreateBuilder(); - - var testResource = builder.AddResource(new TestResource("myResource")) - .WithResourceLogger(); - - var annotation = testResource.Resource.Annotations.OfType().SingleOrDefault(); - - Assert.NotNull(annotation); - - annotation.Logger.LogInformation("Hello, world!"); - annotation.Logger.LogError("Hello, error!"); - annotation.Complete(); - annotation.Logger.LogInformation("Hello, again!"); - - var allLogs = annotation.WatchAsync().ToBlockingEnumerable().SelectMany(x => x).ToList(); - - Assert.Collection(allLogs, - log => Assert.Equal("Hello, world!", log.Content), - log => Assert.Equal("Hello, error!", log.Content)); - - Assert.DoesNotContain("Hello, again!", allLogs.Select(x => x.Content)); - - await using var backlogEnumerator = annotation.WatchAsync().GetAsyncEnumerator(); - Assert.True(await backlogEnumerator.MoveNextAsync()); - Assert.Equal("Hello, world!", backlogEnumerator.Current[0].Content); - Assert.Equal("Hello, error!", backlogEnumerator.Current[1].Content); - - // We're done - Assert.False(await backlogEnumerator.MoveNextAsync()); - } - - private sealed class TestResource(string name) : Resource(name) - { - - } -} diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs new file mode 100644 index 0000000000..5fae9a2430 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -0,0 +1,155 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Hosting.Tests; + +public class ResourceNotificationTests +{ + [Fact] + public void InitialStateCanBeSpecified() + { + var builder = DistributedApplication.CreateBuilder(); + + var custom = builder.AddResource(new CustomResource("myResource")) + .WithEndpoint(name: "ep", scheme: "http", hostPort: 8080) + .WithEnvironment("x", "1000") + .WithInitialState(new() + { + ResourceType = "MyResource", + Properties = [("A", "B")], + }); + + var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); + + Assert.NotNull(annotation); + + var state = annotation.InitialSnapshot; + + Assert.Equal("MyResource", state.ResourceType); + Assert.Empty(state.EnvironmentVariables); + Assert.Collection(state.Properties, c => + { + Assert.Equal("A", c.Key); + Assert.Equal("B", c.Value); + }); + } + + [Fact] + public async Task ResourceUpdatesAreQueued() + { + var resource = new CustomResource("myResource"); + + var notificationService = new ResourceNotificationService(); + + async Task> GetValuesAsync() + { + var values = new List(); + + await foreach (var item in notificationService.WatchAsync(resource)) + { + values.Add(item); + } + + return values; + } + + var enumerableTask = GetValuesAsync(); + + await notificationService.PublishUpdateAsync(resource, state => state with { Properties = state.Properties.Add(("A", "value")) }); + + await notificationService.PublishUpdateAsync(resource, state => state with { Properties = state.Properties.Add(("B", "value")) }); + + notificationService.Complete(resource); + + var values = await enumerableTask; + + // Watch returns an initial snapshot + Assert.Empty(values[0].Properties); + Assert.Equal("value", values[1].Properties.Single(p => p.Key == "A").Value); + Assert.Equal("value", values[2].Properties.Single(p => p.Key == "B").Value); + } + + [Fact] + public async Task WatchReturnsAnInitialState() + { + var resource = new CustomResource("myResource"); + + var notificationService = new ResourceNotificationService(); + + async Task> GetValuesAsync() + { + var values = new List(); + + await foreach (var item in notificationService.WatchAsync(resource)) + { + values.Add(item); + } + + return values; + } + + var enumerableTask = GetValuesAsync(); + + notificationService.Complete(resource); + + var values = await enumerableTask; + + // Watch returns an initial snapshot + var snapshot = Assert.Single(values); + + Assert.Equal("CustomResource", snapshot.ResourceType); + Assert.Empty(snapshot.EnvironmentVariables); + Assert.Empty(snapshot.Properties); + } + + [Fact] + public async Task WatchReturnsAnInitialStateIfCustomized() + { + var resource = new CustomResource("myResource"); + resource.Annotations.Add(new ResourceSnapshotAnnotation(new CustomResourceSnapshot + { + ResourceType = "CustomResource1", + Properties = [("A", "B")], + })); + + var notificationService = new ResourceNotificationService(); + + async Task> GetValuesAsync() + { + var values = new List(); + + await foreach (var item in notificationService.WatchAsync(resource)) + { + values.Add(item); + } + + return values; + } + + var enumerableTask = GetValuesAsync(); + + notificationService.Complete(resource); + + var values = await enumerableTask; + + // Watch returns an initial snapshot + var snapshot = Assert.Single(values); + + Assert.Equal("CustomResource1", snapshot.ResourceType); + Assert.Empty(snapshot.EnvironmentVariables); + Assert.Collection(snapshot.Properties, c => + { + Assert.Equal("A", c.Key); + Assert.Equal("B", c.Value); + }); + } + + private sealed class CustomResource(string name) : Resource(name), + IResourceWithEnvironment, + IResourceWithConnectionString + { + public string? GetConnectionString() => "CustomConnectionString"; + } +} diff --git a/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs b/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs deleted file mode 100644 index 82d17dacfd..0000000000 --- a/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Aspire.Hosting.Tests; - -public class ResourceUpdatesTests -{ - [Fact] - public async Task CreatePopulatesStateFromResource() - { - var builder = DistributedApplication.CreateBuilder(); - - var custom = builder.AddResource(new CustomResource("myResource")) - .WithEndpoint(name: "ep", scheme: "http", hostPort: 8080) - .WithEnvironment("x", "1000") - .WithResourceUpdates(); - - var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); - - Assert.NotNull(annotation); - - var state = await annotation.GetInitialSnapshotAsync(); - - Assert.Equal("Custom", state.ResourceType); - - Assert.Collection(state.EnvironmentVariables, a => - { - Assert.Equal("x", a.Name); - Assert.Equal("1000", a.Value); - }); - - Assert.Collection(state.Properties, c => - { - Assert.Equal("ConnectionString", c.Key); - Assert.Equal("CustomConnectionString", c.Value); - }); - - Assert.Collection(state.Urls, u => - { - Assert.Equal("http://localhost:8080", u); - }); - } - - [Fact] - public async Task InitialStateCanBeSpecified() - { - var builder = DistributedApplication.CreateBuilder(); - - var custom = builder.AddResource(new CustomResource("myResource")) - .WithEndpoint(name: "ep", scheme: "http", hostPort: 8080) - .WithEnvironment("x", "1000") - .WithResourceUpdates(() => new() - { - ResourceType = "MyResource", - Properties = [("A", "B")], - }); - - var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); - - Assert.NotNull(annotation); - - var state = await annotation.GetInitialSnapshotAsync(); - - Assert.Equal("MyResource", state.ResourceType); - Assert.Empty(state.EnvironmentVariables); - Assert.Collection(state.Properties, c => - { - Assert.Equal("A", c.Key); - Assert.Equal("B", c.Value); - }); - } - - [Fact] - public async Task ResourceUpdatesAreQueued() - { - var builder = DistributedApplication.CreateBuilder(); - - var custom = builder.AddResource(new CustomResource("myResource")) - .WithEndpoint(name: "ep", scheme: "http", hostPort: 8080) - .WithEnvironment("x", "1000") - .WithResourceUpdates(); - - var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); - - Assert.NotNull(annotation); - - async Task> GetValuesAsync() - { - var values = new List(); - - await foreach (var item in annotation.WatchAsync()) - { - values.Add(item); - } - - return values; - } - - var enumerableTask = GetValuesAsync(); - - var state = await annotation.GetInitialSnapshotAsync(); - - state = state with { Properties = state.Properties.Add(("A", "value")) }; - - await annotation.UpdateStateAsync(state); - - state = state with { Properties = state.Properties.Add(("B", "value")) }; - - await annotation.UpdateStateAsync(state); - - annotation.Complete(); - - var values = await enumerableTask; - - Assert.Equal("value", values[0].Properties.Single(p => p.Key == "A").Value); - Assert.Equal("value", values[1].Properties.Single(p => p.Key == "B").Value); - } - - private sealed class CustomResource(string name) : Resource(name), - IResourceWithEnvironment, - IResourceWithConnectionString - { - public string? GetConnectionString() => "CustomConnectionString"; - } -}