diff --git a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs index 15a5fc1fc06..eaa8f170061 100644 --- a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs +++ b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs @@ -6,8 +6,8 @@ builder.AddAzureProvisioning(); var db = builder.AddAzureCosmosDB("cosmos") - .AddDatabase("db") - .RunAsEmulator(); + .AddDatabase("db"); + // .RunAsEmulator(); builder.AddProject("api") .WithReference(db); diff --git a/playground/CustomResources/CustomResources.AppHost/TestResource.cs b/playground/CustomResources/CustomResources.AppHost/TestResource.cs index 238c2767ea5..b72098e0e6b 100644 --- a/playground/CustomResources/CustomResources.AppHost/TestResource.cs +++ b/playground/CustomResources/CustomResources.AppHost/TestResource.cs @@ -12,8 +12,8 @@ public static IResourceBuilder AddTestResource(this IDistributedAp builder.Services.AddLifecycleHook(); var rb = builder.AddResource(new TestResource(name)) - .WithResourceLogger() - .WithResourceUpdates(() => new() + .WithResourceLogging() + .WithResourceUpdates(new() { ResourceType = "Test Resource", State = "Starting", @@ -32,30 +32,26 @@ internal sealed class TestResourceLifecycleHook : IDistributedApplicationLifecyc { private readonly CancellationTokenSource _tokenSource = new(); - public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + public Task AfterResourcesProcessedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { foreach (var item in appModel.Resources.OfType()) { - if (item.TryGetLastAnnotation(out var resourceUpdates) && - item.TryGetLastAnnotation(out var loggerAnnotation)) + if (item.TryGetLastAnnotation(out var loggerAnnotation) && + item.TryGetLastAnnotation(out var snapshotAnnotation)) { 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); - 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); // This might run before the dashboard is ready to receive updates, but it will be queued. - await resourceUpdates.UpdateStateAsync(state); + await snapshotAnnotation.PublishUpdateAsync(state => state with + { + Properties = [.. state.Properties, ("Interval", seconds.ToString(CultureInfo.InvariantCulture))] + }); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(seconds)); @@ -63,14 +59,12 @@ public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationT { var randomState = states[Random.Shared.Next(0, states.Length)]; - state = state with + await snapshotAnnotation.PublishUpdateAsync(state => state with { State = randomState - }; + }); loggerAnnotation.Logger.LogInformation("Test resource {ResourceName} is now in state {State}", item.Name, randomState); - - await resourceUpdates.UpdateStateAsync(state); } }, cancellationToken); diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor index 7c646a59898..87e580ccc3e 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 18e51b60f8a..6509e8a93c9 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.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs index 100283dfbb6..1c52b70104a 100644 --- a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureProvisioner.cs @@ -52,14 +52,41 @@ 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()) + { + return Task.CompletedTask; + } + + foreach (var r in azureResources) + { + r.ProvisioningTaskCompletionSource = new(); + + // We're going to be provisioning this resource so we need to add the annotations + var updatesAnnotation = new ResourceUpdatesAnnotation(); + r.Annotations.Add(updatesAnnotation); + r.Annotations.Add(new ResourceSnapshotAnnotation(new() + { + State = "Starting", + ResourceType = r.GetType().Name.Replace("Resource", ""), + Properties = [], + }, + updatesAnnotation)); + } + + return Task.CompletedTask; + } + + public async Task AfterResourcesProcessedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { var azureResources = appModel.Resources.Select(PromoteAzureResourceFromAnnotation).OfType(); if (!azureResources.OfType().Any()) { @@ -86,7 +113,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 +121,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 +150,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 +223,7 @@ await PopulateExistingAspireResources( ResourceGroupResource? resourceGroup = null; SubscriptionResource? subscription = null; + TenantResource? tenant = null; Dictionary? resourceMap = null; UserPrincipal? principal = null; ProvisioningContext? provisioningContext = null; @@ -219,26 +258,56 @@ await PopulateExistingAspireResources( var provisioner = SelectProvisioner(resource); + resource.TryGetLastAnnotation(out var loggerAnnotation); + resource.TryGetLastAnnotation(out var snapshotAnnotation); + if (provisioner is null) { + loggerAnnotation?.Logger.LogWarning("No provisioner found for {resourceType} skipping.", resource.GetType().Name); + + if (snapshotAnnotation is not null) + { + await snapshotAnnotation.PublishUpdateAsync(state => state with { State = "Running" }).ConfigureAwait(false); + } + logger.LogWarning("No provisioner found for {resourceType} skipping.", resource.GetType().Name); continue; } if (!provisioner.ShouldProvision(configuration, resource)) { + loggerAnnotation?.Logger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.Name); + + if (snapshotAnnotation is not null) + { + await snapshotAnnotation.PublishUpdateAsync(state => state with { State = "Running" }).ConfigureAwait(false); + + } + logger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.Name); continue; } - if (provisioner.ConfigureResource(configuration, resource)) + if (await provisioner.ConfigureResourceAsync(configuration, resource).ConfigureAwait(false)) { + if (snapshotAnnotation is not null) + { + await snapshotAnnotation.PublishUpdateAsync(state => state with { State = "Running" }).ConfigureAwait(false); + } + + loggerAnnotation?.Logger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.Name); + logger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.Name); continue; } - subscription ??= await subscriptionLazy.Value.ConfigureAwait(false); + loggerAnnotation?.Logger.LogInformation("Provisioning {resourceName}...", resource.Name); + + if (subscription is null || tenant is null) + { + (subscription, tenant) = await subscriptionLazy.Value.ConfigureAwait(false); + } AzureLocation location = default; @@ -249,7 +318,7 @@ await PopulateExistingAspireResources( resourceMap ??= await resourceMapLazy.Value.ConfigureAwait(false); principal ??= await principalLazy.Value.ConfigureAwait(false); - provisioningContext ??= new ProvisioningContext(credential, armClientLazy.Value, subscription, resourceGroup, resourceMap, location, principal, userSecrets); + provisioningContext ??= new ProvisioningContext(credential, armClientLazy.Value, subscription, resourceGroup, tenant, resourceMap, location, principal, userSecrets); var task = provisioner.GetOrCreateResourceAsync( resource, @@ -258,7 +327,6 @@ await PopulateExistingAspireResources( tasks.Add(task); } - if (tasks.Count > 0) { var task = Task.WhenAll(tasks); @@ -266,6 +334,12 @@ await PopulateExistingAspireResources( // Suppress throwing so that we can save the user secrets even if the task fails await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + // Set the completion source for all resources + foreach (var resource in azureResources) + { + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + } + // If we created any resources then save the user secrets if (userSecretsPath is not null) { @@ -279,6 +353,14 @@ await PopulateExistingAspireResources( // 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 () => diff --git a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureResourceProvisionerOfT.cs b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/AzureResourceProvisionerOfT.cs index 9765433c102..f60e6555b7f 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); 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) => + ConfigureResourceAsync(configuration, (TResource)resource); 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); 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 0373bd52ba4..b52f0e33784 100644 --- a/src/Aspire.Hosting.Azure.Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure.Provisioning/Provisioners/BicepProvisioner.cs @@ -22,8 +22,10 @@ internal sealed class BicepProvisioner(ILogger logger) : Azure 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) { + resource.TryGetLastAnnotation(out var snapshotAnnotation); + var section = configuration.GetSection($"Azure:Deployments:{resource.Name}"); if (!section.Exists()) @@ -39,9 +41,30 @@ public override bool ConfigureResource(IConfiguration configuration, AzureBicepR return false; } - foreach (var item in section.GetSection("Outputs").GetChildren()) + 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 +72,55 @@ public override bool ConfigureResource(IConfiguration configuration, AzureBicepR resource.SecretOutputs[item.Key] = item.Value; } + var portalUrls = new List<(string, string)>(); + foreach (var pair in section.GetSection("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 + portalUrls.Add(("deployment", $"https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id/resource{section["Id"]}")); + + if (snapshotAnnotation is not null) + { + await snapshotAnnotation.PublishUpdateAsync(state => state with + { + State = "Running", + Urls = [.. portalUrls], + Properties = [ + .. 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"]!) + ] + }).ConfigureAwait(false); + } + return true; } public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) { + resource.TryGetLastAnnotation(out var loggerAnnotation); + resource.TryGetLastAnnotation(out var snapshotAnnotation); + + if (snapshotAnnotation is not null) + { + await snapshotAnnotation.PublishUpdateAsync(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); + } + PopulateWellKnownParameters(resource, context); var azPath = FindFullPathFromPath("az") ?? @@ -76,7 +143,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); + loggerAnnotation?.Logger.LogInformation("Found key vault {vaultName} for resource {resource} in {location}...", kv.Data.Name, resource.Name, context.Location); keyVault = kv; break; @@ -89,7 +156,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); + loggerAnnotation?.Logger.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 +169,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); + loggerAnnotation?.Logger.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,7 +197,7 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, var deployments = context.ResourceGroup.GetArmDeployments(); - logger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, context.ResourceGroup.Data.Name); + loggerAnnotation?.Logger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, context.ResourceGroup.Data.Name); // Convert the parameters to a JSON object var parameters = new JsonObject(); @@ -146,7 +213,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); + loggerAnnotation?.Logger.LogInformation("Deployment of {Name} to {ResourceGroup} took {Elapsed}", resource.Name, context.ResourceGroup.Data.Name, sw.Elapsed); var deployment = operation.Value; @@ -161,38 +228,59 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, throw new InvalidOperationException($"Deployment of {resource.Name} to {context.ResourceGroup.Data.Name} failed with {deployment.Data.Properties.ProvisioningState}"); } + foreach (var o in deployment.Data.Properties.OutputResources) + { + loggerAnnotation?.Logger.Log(LogLevel.Information, 0, o.Id, null, (s, e) => s.ToString()); + } + // e.g. { "sqlServerName": { "type": "String", "value": "" }} 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); + // 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 +303,19 @@ public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, configOutputs[item.Key] = resource.SecretOutputs[item.Key]; } } + + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + + if (snapshotAnnotation is not null) + { + await snapshotAnnotation.PublishUpdateAsync(state => state with + { + State = "Running", + Properties = [.. state.Properties, (CustomResourceKnownProperties.Source, deployment.Id.Name)], + Urls = [.. portalUrls] + }) + .ConfigureAwait(false); + } } private static void PopulateWellKnownParameters(AzureBicepResource resource, ProvisioningContext context) diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index ecde2fda914..41fe48ff48a 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. /// diff --git a/src/Aspire.Hosting.Azure/AzureCosmosDBResource.cs b/src/Aspire.Hosting.Azure/AzureCosmosDBResource.cs index 70cb3aa3101..bec5db077e1 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.ConfigureAwait(false); + } + + return new(GetConnectionString()); + } + /// /// Gets the connection string to use for this database. /// diff --git a/src/Aspire.Hosting.Azure/AzureOpenAIResource.cs b/src/Aspire.Hosting.Azure/AzureOpenAIResource.cs index cf5e467a147..5c732841d41 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; + /// + /// + /// + public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; } + internal void AddDeployment(AzureOpenAIDeploymentResource deployment) { if (deployment.Parent != this) diff --git a/src/Aspire.Hosting.Azure/IAzureResource.cs b/src/Aspire.Hosting.Azure/IAzureResource.cs index d4e68be31f8..c42516554c0 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 24544ab11bd..1bed5350faf 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceExtensions.cs @@ -15,27 +15,20 @@ public static class CustomResourceExtensions /// /// 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 WithResourceUpdates(this IResourceBuilder builder, CustomResourceSnapshot? initialSnapshot = null) where TResource : IResource { - initialSnapshotFactory ??= cancellationToken => CustomResourceSnapshot.CreateAsync(builder.Resource, cancellationToken); + var updates = new ResourceUpdatesAnnotation(); + builder.WithAnnotation(updates, ResourceAnnotationMutationBehavior.Replace); - return builder.WithAnnotation(new ResourceUpdatesAnnotation(initialSnapshotFactory), ResourceAnnotationMutationBehavior.Replace); - } + if (initialSnapshot != null) + { + builder.WithAnnotation(new ResourceSnapshotAnnotation(initialSnapshot, updates), 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); + return builder; } /// @@ -44,7 +37,7 @@ public static IResourceBuilder WithResourceUpdates(this IR /// The resource. /// The resource builder. /// The resource builder. - public static IResourceBuilder WithResourceLogger(this IResourceBuilder builder) + public static IResourceBuilder WithResourceLogging(this IResourceBuilder builder) where TResource : IResource { return builder.WithAnnotation(new ResourceLoggerAnnotation(), ResourceAnnotationMutationBehavior.Replace); diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs index 888d79b9880..2b6b443c85e 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceUpdatesAnnotation.cs @@ -10,7 +10,7 @@ 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 +public sealed class ResourceUpdatesAnnotation : IResourceAnnotation { private readonly CancellationTokenSource _streamClosedCts = new(); @@ -21,16 +21,11 @@ public sealed class ResourceUpdatesAnnotation(Func 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) + public Task PublishUpdateAsync(CustomResourceSnapshot state) { if (_streamClosedCts.IsCancellationRequested) { @@ -80,11 +75,68 @@ void WriteToChannel(CustomResourceSnapshot state) } } +/// +/// An annotation that keeps track of the last known state of a resource. +/// +public class ResourceSnapshotAnnotation(CustomResourceSnapshot initialSnapshot, ResourceUpdatesAnnotation resourceUpdates) : IResourceAnnotation +{ + private CustomResourceSnapshot _currentSnapshot = initialSnapshot; + + /// + /// Gets or sets the current snapshot of the resource. + /// + public CustomResourceSnapshot CurrentSnapshot + { + get => _currentSnapshot; + set => _currentSnapshot = value; + } + + /// + /// + /// + public ResourceUpdatesAnnotation ResourceUpdates { get; } = resourceUpdates; + + /// + /// Updates the snapshot of the for a resource. + /// + /// A callback that creates a new snapshot from the current snapshot + /// The snapshot. + public CustomResourceSnapshot UpdateSnapshot(Func snapshotFactory) + { + return _currentSnapshot = snapshotFactory(_currentSnapshot); + } + + /// + /// + /// + /// + public Task PublishUpdateAsync() + { + return ResourceUpdates.PublishUpdateAsync(_currentSnapshot); + } + + /// + /// + /// + /// + /// + /// + public Task PublishUpdateAsync(Func snapshotFactory) + { + return ResourceUpdates.PublishUpdateAsync(UpdateSnapshot(snapshotFactory)); + } +} + /// /// An immutable snapshot of the state of a resource. /// public sealed record CustomResourceSnapshot { + /// + /// An empty . + /// + public static readonly CustomResourceSnapshot Empty = new() { Properties = [], ResourceType = "" }; + /// /// The type of the resource. /// @@ -108,7 +160,7 @@ public sealed record CustomResourceSnapshot /// /// The URLs that should show up in the dashboard for this resource. /// - public ImmutableArray Urls { get; init; } = []; + public ImmutableArray<(string Name, string Url)> Urls { get; init; } = []; /// /// Creates a new for a resource using the well known annotations. @@ -118,14 +170,14 @@ public sealed record CustomResourceSnapshot /// The new . public static async ValueTask CreateAsync(IResource resource, CancellationToken cancellationToken = default) { - ImmutableArray urls = []; + ImmutableArray<(string Name, string Url)> 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))]; + urls = [.. endpointAnnotations.Where(e => e.Port is not null).Select(e => (e.Name, GetUrl(e)))]; } ImmutableArray<(string, string)> environmentVariables = []; @@ -141,19 +193,13 @@ static string GetUrl(EndpointAnnotation e) => 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 + Properties = [] }; } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 4a0950b3c57..7955f8e07c5 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; +using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -23,14 +24,16 @@ public DashboardServiceData( DistributedApplicationModel applicationModel, IKubernetesService kubernetesService, IConfiguration configuration, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + IEnumerable lifecycleHooks + ) { var resourceMap = applicationModel.Resources.ToDictionary(resource => resource.Name, StringComparer.Ordinal); _resourcePublisher = new ResourcePublisher(_cts.Token); _consoleLogPublisher = new ConsoleLogPublisher(_resourcePublisher, resourceMap, kubernetesService, loggerFactory, configuration); - _ = new DcpDataSource(kubernetesService, resourceMap, configuration, loggerFactory, _resourcePublisher.IntegrateAsync, _cts.Token); + _ = new DcpDataSource(kubernetesService, resourceMap, applicationModel, configuration, loggerFactory, _resourcePublisher.IntegrateAsync, lifecycleHooks, _cts.Token); } public async ValueTask DisposeAsync() diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index d9570351d48..1d4d2e5695e 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -4,6 +4,7 @@ using System.Net; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; +using Aspire.Hosting.Lifecycle; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -52,7 +53,8 @@ public DashboardServiceHost( IConfiguration configuration, DistributedApplicationExecutionContext executionContext, ILoggerFactory loggerFactory, - IConfigureOptions loggerOptions) + IConfigureOptions loggerOptions, + IEnumerable lifecycleHooks) { _logger = loggerFactory.CreateLogger(); @@ -82,6 +84,7 @@ public DashboardServiceHost( builder.Services.AddSingleton(applicationModel); builder.Services.AddSingleton(kubernetesService); builder.Services.AddSingleton(); + builder.Services.AddSingleton(lifecycleHooks); builder.WebHost.ConfigureKestrel(ConfigureKestrel); diff --git a/src/Aspire.Hosting/Dashboard/DcpDataSource.cs b/src/Aspire.Hosting/Dashboard/DcpDataSource.cs index b436e4a19af..e282c30d33a 100644 --- a/src/Aspire.Hosting/Dashboard/DcpDataSource.cs +++ b/src/Aspire.Hosting/Dashboard/DcpDataSource.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.Lifecycle; using k8s; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -36,14 +37,16 @@ internal sealed class DcpDataSource public DcpDataSource( IKubernetesService kubernetesService, - IReadOnlyDictionary applicationModel, + IReadOnlyDictionary applicationModelMap, + DistributedApplicationModel model, IConfiguration configuration, ILoggerFactory loggerFactory, Func onResourceChanged, + IEnumerable lifecycleHooks, CancellationToken cancellationToken) { _kubernetesService = kubernetesService; - _applicationModel = applicationModel; + _applicationModel = applicationModelMap; _onResourceChanged = onResourceChanged; _logger = loggerFactory.CreateLogger(); @@ -61,11 +64,19 @@ public DcpDataSource( using (semaphore) { - await Task.WhenAll( - Task.Run(() => WatchKubernetesResource((t, r) => ProcessResourceChange(t, r, _executablesMap, "Executable", ToSnapshot)), cancellationToken), - Task.Run(() => WatchKubernetesResource((t, r) => ProcessResourceChange(t, r, _containersMap, "Container", ToSnapshot)), cancellationToken), - Task.Run(() => WatchKubernetesResource(ProcessServiceChange), cancellationToken), - Task.Run(() => WatchKubernetesResource(ProcessEndpointChange), cancellationToken)).ConfigureAwait(false); + var executables = Task.Run(() => WatchKubernetesResource((t, r) => ProcessResourceChange(t, r, _executablesMap, "Executable", ToSnapshot)), cancellationToken); + var containers = Task.Run(() => WatchKubernetesResource((t, r) => ProcessResourceChange(t, r, _containersMap, "Container", ToSnapshot)), cancellationToken); + var services = Task.Run(() => WatchKubernetesResource(ProcessServiceChange), cancellationToken); + var endpoints = Task.Run(() => WatchKubernetesResource(ProcessEndpointChange), cancellationToken); + + var tasks = new List([executables, containers, services, endpoints]); + + foreach (var hook in lifecycleHooks) + { + tasks.Add(hook.AfterResourcesProcessedAsync(model, cancellationToken)); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); } }, cancellationToken); @@ -199,14 +210,15 @@ private async Task ProcessInitialResourceAsync(IResource resource, CancellationT } 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); + // See if we have an initial snapshot for the resource + if (resource.TryGetLastAnnotation(out var snapshotAnnotation)) + { + var snapshot = CreateResourceSnapshot(resource, creationTimestamp, snapshotAnnotation.CurrentSnapshot); - await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); + await _onResourceChanged(snapshot, ResourceSnapshotChangeType.Upsert).ConfigureAwait(false); + } _ = Task.Run(async () => { @@ -233,8 +245,11 @@ private static GenericResourceSnapshot CreateResourceSnapshot(IResource resource ImmutableArray environmentVariables = [.. dashboardState.EnvironmentVariables.Select(e => new EnvironmentVariableSnapshot(e.Name, e.Value, false))]; + ImmutableArray services = [.. + dashboardState.Urls.Select(u => new ResourceServiceSnapshot(u.Name, u.Url, null))]; + ImmutableArray endpoints = [.. - dashboardState.Urls.Select(u => new EndpointSnapshot(u, u))]; + dashboardState.Urls.Select(u => new EndpointSnapshot(u.Url, u.Url))]; return new GenericResourceSnapshot(dashboardState) { @@ -246,7 +261,7 @@ private static GenericResourceSnapshot CreateResourceSnapshot(IResource resource Environment = environmentVariables, ExitCode = null, ExpectedEndpointsCount = endpoints.Length, - Services = [], + Services = services, State = dashboardState.State ?? "Running" }; } diff --git a/src/Aspire.Hosting/Extensions/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/Extensions/ParameterResourceBuilderExtensions.cs index a54bb44fa1d..6e885bd6a64 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); + + 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) - .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)] }; - } - }) + .WithResourceUpdates(state) .WithManifestPublishingCallback(context => WriteParameterResourceToManifest(context, resource, connectionString)); } diff --git a/src/Aspire.Hosting/Lifecycle/IDistributedApplicationLifecycleHook.cs b/src/Aspire.Hosting/Lifecycle/IDistributedApplicationLifecycleHook.cs index 0a698de0020..8614f75f5ca 100644 --- a/src/Aspire.Hosting/Lifecycle/IDistributedApplicationLifecycleHook.cs +++ b/src/Aspire.Hosting/Lifecycle/IDistributedApplicationLifecycleHook.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Lifecycle; public interface IDistributedApplicationLifecycleHook { /// - /// Executes before the distributed application starts. + /// Executes before the distributed application starts. This is the last place to make changes to the application model before it is processed. /// /// The distributed application model. /// The cancellation token. @@ -21,6 +21,14 @@ Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken ca return Task.CompletedTask; } + /// + /// Executes after the application model has been processed. + /// + /// + /// + /// + Task AfterResourcesProcessedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken) => Task.CompletedTask; + /// /// Executes after the orchestrator allocates endpoints for resources in the application model. /// diff --git a/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs b/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs index 628bc92cd00..57a5e5712a8 100644 --- a/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceLoggerTests.cs @@ -14,7 +14,7 @@ public void AddingResourceLoggerAnnotationAllowsLogging() var builder = DistributedApplication.CreateBuilder(); var testResource = builder.AddResource(new TestResource("myResource")) - .WithResourceLogger(); + .WithResourceLogging(); var annotation = testResource.Resource.Annotations.OfType().SingleOrDefault(); @@ -46,7 +46,7 @@ public async Task StreamingLogsCancelledAfterComplete() var builder = DistributedApplication.CreateBuilder(); var testResource = builder.AddResource(new TestResource("myResource")) - .WithResourceLogger(); + .WithResourceLogging(); var annotation = testResource.Resource.Annotations.OfType().SingleOrDefault(); diff --git a/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs b/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs index 82d17dacfdf..705c43b1ce4 100644 --- a/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceUpdatesTests.cs @@ -18,10 +18,12 @@ public async Task CreatePopulatesStateFromResource() .WithResourceUpdates(); var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); + var resoucesSnapshotAnnotation = custom.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); + Assert.NotNull(resoucesSnapshotAnnotation); - var state = await annotation.GetInitialSnapshotAsync(); + var state = await CustomResourceSnapshot.CreateAsync(custom.Resource); Assert.Equal("Custom", state.ResourceType); @@ -39,29 +41,30 @@ public async Task CreatePopulatesStateFromResource() Assert.Collection(state.Urls, u => { - Assert.Equal("http://localhost:8080", u); + Assert.Equal("ep", u.Name); + Assert.Equal("http://localhost:8080", u.Url); }); } [Fact] - public async Task InitialStateCanBeSpecified() + public void InitialStateCanBeSpecified() { var builder = DistributedApplication.CreateBuilder(); var custom = builder.AddResource(new CustomResource("myResource")) .WithEndpoint(name: "ep", scheme: "http", hostPort: 8080) .WithEnvironment("x", "1000") - .WithResourceUpdates(() => new() + .WithResourceUpdates(new() { ResourceType = "MyResource", Properties = [("A", "B")], }); - var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); + var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); - var state = await annotation.GetInitialSnapshotAsync(); + var state = annotation.CurrentSnapshot; Assert.Equal("MyResource", state.ResourceType); Assert.Empty(state.EnvironmentVariables); @@ -83,8 +86,10 @@ public async Task ResourceUpdatesAreQueued() .WithResourceUpdates(); var annotation = custom.Resource.Annotations.OfType().SingleOrDefault(); + var snapshotAnnotation = custom.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); + Assert.NotNull(snapshotAnnotation); async Task> GetValuesAsync() { @@ -100,15 +105,11 @@ async Task> GetValuesAsync() var enumerableTask = GetValuesAsync(); - var state = await annotation.GetInitialSnapshotAsync(); - - state = state with { Properties = state.Properties.Add(("A", "value")) }; - - await annotation.UpdateStateAsync(state); + var state = snapshotAnnotation.CurrentSnapshot; - state = state with { Properties = state.Properties.Add(("B", "value")) }; + await snapshotAnnotation.PublishUpdateAsync(state => state with { Properties = state.Properties.Add(("A", "value")) }); - await annotation.UpdateStateAsync(state); + await snapshotAnnotation.PublishUpdateAsync(state => state with { Properties = state.Properties.Add(("B", "value")) }); annotation.Complete();