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"; - } -}