From 1f56e60a894afae321fd4f54ce6999366f1c25bd Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Mon, 11 Dec 2023 21:27:14 +1100 Subject: [PATCH] Extract all data acquisition from ResourceService (#1288) * Extract all data acquisition from ResourceService This creates a new class `KubernetesDataSource` that encapsulates all code related to obtaining data from kubernetes in the `ResourceService`. This cleans the service code up a lot and makes the responsibilities clearer. The use of `Channel` has been removed. Instead, code invocations are made directly, skipping queuing. We still have two other channels that ensure front/back are decoupled. The kubernetes channel will ultimately also be removed, leaving just a channel for the subscriber. Added some API docs. * Check cancellation token * Remove Channel for kubernetes objects This is the second removal of a `Channel` from this component. Now, updates are passed by direct code invocations, skipping queuing. We still have a downstream channel for the subscriber to decouple front/back. * Inline method * Rename ResourceCollection to ResourcePublisher * Simplify inclusion of docker environment A few changes here: - Use more consistent names throughout. They're not "extra" or "additional" arguments. In fact they are a complete replacement. In their absence, the "spec" environment is used. - Move creation of docker inspection task to view model method, making the various "handle * update" methods more uniform, for future refactoring. - Remove special handling in `ProcessKubernetesChange` for one scenario. Simplifies the signature, and the code a fair bit. * Specify list capacity * Merge ObjectChangeType Add and Modified We only need to know if the value is "upserted" (updated or inserted), or "deleted". Recent changes to threading/queuing in the code here changed some timing. The arrival of one kind of resouce can trigger the publication of another, and these would always be "modified", however they could arrive before that resource's stream published that instance. The rest of the code handles these happening out-of-order. We just need to treat add and modified the same way, so they've been merged. * Use semaphore to serialize kubernetes data processing There are multiple kubernetes resource types, each with its own monitoring stream. Across these resources, updates arrive concurrently. The update flows for each type of resource can interact with state stored for other resources. Therefore we use a semaphore to ensure that only one resource update can flow through the system at a time. Another option would have been to make the collections concurrent, or use explicit locking. Concurrent collections are heavy. Explicit locking is tricky to get right. This mutual exclusion via top-level semaphore seems like a safe and elegant approach for now. * Avoid redundant linear scans When this method is passed the same collection twice, the `FromSpec` value will always be true, because every item in the list is in the list. Instead we pass `null` and consider all items as from the spec. * Simplify delegation of projects-as-executables * Avoid switch on every update Each instance of `WatchKubernetesResource` produces a single type, that would flow through `ProcessKubernetesChange` to look up the relevant handler. Instead, pass the handler in to `WatchKubernetesResource` so it can be invoked directly. * Formatting * Rename class to avoid name conflict This object is a snapshot of a service's state, not a service itself. We have another class which is a service. Append "Snapshot" to differentiate. Future work will extend this concept of snapshots more broadly. * Replace O(N) scanning with O(1) lookup * Make resource Endpoints and Services immutable * Make ResourceViewModel.Environment immutable Note that it's just the collection that becomes immutable here. The elements are still currently mutable. That will change when we split front/back ends, and have a snapshot on the backend with a view model on the front end. * Make ContainerViewModel Ports and Args immutable * Make ExecutableViewModel Arguments immutable * Make remaining scalar ExecutableViewModel properties immutable * Make all resource snapshot properties required * Add API docs for resource snapshot types * Rename KubernetesDataSource to DcpDataSource * Use collection literals for empty collections * Source container variables from status This data is now available from DCP, so we don't have to launch processes to query docker for this data any more, which simplifies things quite nicely. * Allocate less memory in ProcessUtil * Extract duplicate code * Further deduplicate code * Make ProjectViewModel derive from ExecutableViewModel * Remove duplicate razor after consolidating types * Merge project/executable snapshot construction Now that project snapshots derive from executable snapshots, we can unify a bunch of construction logic. * Replace O(N) scanning with O(1) lookup * Reorder methods * Start using "snapshot" naming in DCP data source We still have some types named ViewModel, but they'll be renamed later in the split. * Rename ObjectChangeType to ResourceChangeType It's not clear what object the name refers to, so make it more specific. It's internal, so we can always rename it again later. This name pairs nicely with `ObjectChange` too. * Further consolidate data processing The methods that handle executable and container updates are largely the same. Extract that commonality to a new method. --- .../Components/Pages/ConsoleLogs.razor.cs | 12 +- .../Components/Pages/Resources.razor.cs | 14 +- .../EndpointsColumnDisplay.razor | 4 +- .../ResourceNameDisplay.razor | 8 +- .../SourceColumnDisplay.razor | 5 +- .../Model/ContainerViewModel.cs | 13 +- .../Model/ExecutableViewModel.cs | 14 +- .../Model/ObjectChangeType.cs | 12 - .../Model/ProjectViewModel.cs | 7 +- src/Aspire.Dashboard/Model/ResourceChange.cs | 2 +- .../Model/ResourceChangeType.cs | 19 + .../Model/ResourceOutgoingPeerResolver.cs | 12 +- .../Model/ResourceViewModel.cs | 19 +- src/Aspire.Hosting/Dashboard/DcpDataSource.cs | 430 +++++++++++++ ...urceCollection.cs => ResourcePublisher.cs} | 89 ++- .../Dashboard/ResourceService.cs | 603 +----------------- src/Aspire.Hosting/Dcp/DcpHostService.cs | 2 +- src/Aspire.Hosting/Dcp/Model/Container.cs | 4 + src/Aspire.Hosting/Dcp/Model/Executable.cs | 2 - src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs | 6 +- 20 files changed, 558 insertions(+), 719 deletions(-) delete mode 100644 src/Aspire.Dashboard/Model/ObjectChangeType.cs create mode 100644 src/Aspire.Dashboard/Model/ResourceChangeType.cs create mode 100644 src/Aspire.Hosting/Dashboard/DcpDataSource.cs rename src/Aspire.Hosting/Dashboard/{ResourceCollection.cs => ResourcePublisher.cs} (56%) diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 668554aa44..35680667d2 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -187,15 +187,12 @@ private async Task HandleSelectedOptionChangedAsync() NavigationManager.NavigateTo($"/ConsoleLogs/{_selectedOption?.Value}"); } - private async Task OnResourceListChangedAsync(ObjectChangeType changeType, ResourceViewModel resourceViewModel) + private async Task OnResourceListChangedAsync(ResourceChangeType changeType, ResourceViewModel resourceViewModel) { - if (changeType == ObjectChangeType.Added) - { - _resourceNameMapping[resourceViewModel.Name] = resourceViewModel; - } - else if (changeType == ObjectChangeType.Modified) + if (changeType == ResourceChangeType.Upsert) { _resourceNameMapping[resourceViewModel.Name] = resourceViewModel; + if (string.Equals(_selectedResource?.Name, resourceViewModel.Name, StringComparison.Ordinal)) { _selectedResource = resourceViewModel; @@ -210,9 +207,10 @@ private async Task OnResourceListChangedAsync(ObjectChangeType changeType, Resou } } } - else if (changeType == ObjectChangeType.Deleted) + else if (changeType == ResourceChangeType.Deleted) { _resourceNameMapping.Remove(resourceViewModel.Name); + if (string.Equals(_selectedResource?.Name, resourceViewModel.Name, StringComparison.Ordinal)) { _selectedOption = _noSelection; diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index ff2fd0ff7a..bc91a48bc2 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -130,7 +130,7 @@ private int GetUnviewedErrorCount(ResourceViewModel resource) private void ShowEnvironmentVariables(ResourceViewModel resource) { - if (SelectedEnvironmentVariables == resource.Environment) + if (SelectedResource == resource) { ClearSelectedResource(); } @@ -147,19 +147,15 @@ private void ClearSelectedResource() SelectedResource = null; } - private async Task OnResourceListChanged(ObjectChangeType objectChangeType, ResourceViewModel resource) + private async Task OnResourceListChanged(ResourceChangeType changeType, ResourceViewModel resource) { - switch (objectChangeType) + switch (changeType) { - case ObjectChangeType.Added: - _resourcesMap.Add(resource.Name, resource); - break; - - case ObjectChangeType.Modified: + case ResourceChangeType.Upsert: _resourcesMap[resource.Name] = resource; break; - case ObjectChangeType.Deleted: + case ResourceChangeType.Deleted: _resourcesMap.Remove(resource.Name); break; } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor index 5b22e17c7e..5a81ce7abb 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/EndpointsColumnDisplay.razor @@ -2,7 +2,7 @@ @* If we have no endpoints, and the app isn't running anymore or we're not expecting any, then just say None *@ - @if (Resource.Endpoints.Count == 0 && (Resource.State == FinishedState || Resource.ExpectedEndpointsCount == 0)) + @if (Resource.Endpoints.Length == 0 && (Resource.State == FinishedState || Resource.ExpectedEndpointsCount == 0)) { None } @@ -15,7 +15,7 @@ } @* If we're expecting more, say Starting..., unless the app isn't running anymore *@ if (Resource.State != FinishedState - && (Resource.ExpectedEndpointsCount is null || Resource.ExpectedEndpointsCount > Resource.Endpoints.Count)) + && (Resource.ExpectedEndpointsCount is null || Resource.ExpectedEndpointsCount > Resource.Endpoints.Length)) { Starting... } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/ResourceNameDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/ResourceNameDisplay.razor index 92af5458dd..ae05d85ee2 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/ResourceNameDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/ResourceNameDisplay.razor @@ -2,12 +2,7 @@ - @if (Resource is ProjectViewModel projectViewModel) - { - var title = $"Process ID: {projectViewModel.ProcessId}"; - @projectViewModel.ProcessId - } - else if (Resource is ContainerViewModel containerViewModel) + @if (Resource is ContainerViewModel containerViewModel) {
@executableViewModel.ProcessId } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor index e47b7cdf9f..a4f0f62d40 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor @@ -3,6 +3,7 @@ @if (Resource is ProjectViewModel projectViewModel) { + // NOTE projects are also executables, so we have to check for projects first @Path.GetFileName(projectViewModel.ProjectPath) } else if (Resource is ExecutableViewModel executableViewModel) @@ -18,9 +19,9 @@ else if (Resource is ContainerViewModel containerViewModel) var ports = string.Join("; ", containerViewModel.Ports); - @if (containerViewModel.Ports.Count > 0) + @if (containerViewModel.Ports.Length > 0) { - var title = $"Port{(containerViewModel.Ports.Count > 1 ? "s" : string.Empty)}: {ports}"; + var title = $"Port{(containerViewModel.Ports.Length > 1 ? "s" : string.Empty)}: {ports}"; @ports } diff --git a/src/Aspire.Dashboard/Model/ContainerViewModel.cs b/src/Aspire.Dashboard/Model/ContainerViewModel.cs index bc61fb8280..7106245643 100644 --- a/src/Aspire.Dashboard/Model/ContainerViewModel.cs +++ b/src/Aspire.Dashboard/Model/ContainerViewModel.cs @@ -1,18 +1,23 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Aspire.Dashboard.Extensions; namespace Aspire.Dashboard.Model; +/// +/// Immutable snapshot of container state at a point in time. +/// public class ContainerViewModel : ResourceViewModel { public override string ResourceType => "Container"; - public string? ContainerId { get; init; } + + public required string? ContainerId { get; init; } public required string Image { get; init; } - public List Ports { get; } = new(); - public string? Command { get; init; } - public List? Args { get; init; } + public required ImmutableArray Ports { get; init; } + public required string? Command { get; init; } + public required ImmutableArray? Args { get; init; } internal override bool MatchesFilter(string filter) { diff --git a/src/Aspire.Dashboard/Model/ExecutableViewModel.cs b/src/Aspire.Dashboard/Model/ExecutableViewModel.cs index 23d991b04c..bb4df2feba 100644 --- a/src/Aspire.Dashboard/Model/ExecutableViewModel.cs +++ b/src/Aspire.Dashboard/Model/ExecutableViewModel.cs @@ -1,14 +1,20 @@ // 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.Dashboard.Model; +/// +/// Immutable snapshot of executable state at a point in time. +/// public class ExecutableViewModel : ResourceViewModel { public override string ResourceType => "Executable"; - public int? ProcessId { get; init; } - public string? ExecutablePath { get; set; } - public string? WorkingDirectory { get; set; } - public List? Arguments { get; set; } + + public required int? ProcessId { get; init; } + public required string? ExecutablePath { get; init; } + public required string? WorkingDirectory { get; init; } + public required ImmutableArray? Arguments { get; init; } } diff --git a/src/Aspire.Dashboard/Model/ObjectChangeType.cs b/src/Aspire.Dashboard/Model/ObjectChangeType.cs deleted file mode 100644 index f3d7282f40..0000000000 --- a/src/Aspire.Dashboard/Model/ObjectChangeType.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Dashboard.Model; - -public enum ObjectChangeType -{ - Other, - Added, - Modified, - Deleted -} diff --git a/src/Aspire.Dashboard/Model/ProjectViewModel.cs b/src/Aspire.Dashboard/Model/ProjectViewModel.cs index b11478a50e..79488b5329 100644 --- a/src/Aspire.Dashboard/Model/ProjectViewModel.cs +++ b/src/Aspire.Dashboard/Model/ProjectViewModel.cs @@ -3,9 +3,12 @@ namespace Aspire.Dashboard.Model; -public class ProjectViewModel : ResourceViewModel +/// +/// Immutable snapshot of project state at a point in time. +/// +public class ProjectViewModel : ExecutableViewModel { public override string ResourceType => "Project"; - public int? ProcessId { get; init; } + public required string ProjectPath { get; init; } } diff --git a/src/Aspire.Dashboard/Model/ResourceChange.cs b/src/Aspire.Dashboard/Model/ResourceChange.cs index afbd140fc7..dfb03bbdc8 100644 --- a/src/Aspire.Dashboard/Model/ResourceChange.cs +++ b/src/Aspire.Dashboard/Model/ResourceChange.cs @@ -3,4 +3,4 @@ namespace Aspire.Dashboard.Model; -public sealed record ResourceChange(ObjectChangeType ObjectChangeType, ResourceViewModel Resource); +public sealed record ResourceChange(ResourceChangeType ChangeType, ResourceViewModel Resource); diff --git a/src/Aspire.Dashboard/Model/ResourceChangeType.cs b/src/Aspire.Dashboard/Model/ResourceChangeType.cs new file mode 100644 index 0000000000..2c74ad1e90 --- /dev/null +++ b/src/Aspire.Dashboard/Model/ResourceChangeType.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model; + +public enum ResourceChangeType +{ + Other, + + /// + /// The object was added if new, or updated if not. + /// + Upsert, + + /// + /// The object was deleted. + /// + Deleted +} diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 5919635b08..0ef203f788 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -33,17 +33,13 @@ public ResourceOutgoingPeerResolver(IResourceService resourceService) }); } - private async Task OnResourceListChanged(ObjectChangeType changeType, ResourceViewModel resourceViewModel) + private async Task OnResourceListChanged(ResourceChangeType changeType, ResourceViewModel resourceViewModel) { - if (changeType == ObjectChangeType.Added) + if (changeType == ResourceChangeType.Upsert) { _resourceNameMapping[resourceViewModel.Name] = resourceViewModel; } - else if (changeType == ObjectChangeType.Modified) - { - _resourceNameMapping[resourceViewModel.Name] = resourceViewModel; - } - else if (changeType == ObjectChangeType.Deleted) + else if (changeType == ResourceChangeType.Deleted) { _resourceNameMapping.TryRemove(resourceViewModel.Name, out _); } @@ -93,7 +89,7 @@ private void RemoveSubscription(ModelSubscription subscription) private async Task RaisePeerChangesAsync() { - if (_subscriptions.Count == 0) + if (_subscriptions.Count == 0 || _watchContainersTokenSource.IsCancellationRequested) { return; } diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index 795e2d5674..932e307ebe 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -1,22 +1,27 @@ // 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 Aspire.Dashboard.Extensions; namespace Aspire.Dashboard.Model; +/// +/// Base class for immutable snapshots of resource state at a point in time. +/// public abstract class ResourceViewModel { public required string Name { get; init; } public required string DisplayName { get; init; } public required string Uid { get; init; } - public string? State { get; init; } - public DateTime? CreationTimeStamp { get; init; } - public List Environment { get; } = new(); + public required string? State { get; init; } + public required DateTime? CreationTimeStamp { get; init; } + public required ImmutableArray Environment { get; init; } public required ILogSource LogSource { get; init; } - public List Endpoints { get; } = new(); - public List Services { get; } = new(); - public int? ExpectedEndpointsCount { get; init; } + public required ImmutableArray Endpoints { get; init; } + public required ImmutableArray Services { get; init; } + public required int? ExpectedEndpointsCount { get; init; } + public abstract string ResourceType { get; } public static string GetResourceName(ResourceViewModel resource, IEnumerable allResources) @@ -43,7 +48,7 @@ internal virtual bool MatchesFilter(string filter) } } -public sealed class ResourceService(string name, string? allocatedAddress, int? allocatedPort) +public sealed class ResourceServiceSnapshot(string name, string? allocatedAddress, int? allocatedPort) { public string Name { get; } = name; public string? AllocatedAddress { get; } = allocatedAddress; diff --git a/src/Aspire.Hosting/Dashboard/DcpDataSource.cs b/src/Aspire.Hosting/Dashboard/DcpDataSource.cs new file mode 100644 index 0000000000..a9e1df02d0 --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/DcpDataSource.cs @@ -0,0 +1,430 @@ +// 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.Text.Json; +using Aspire.Dashboard.Model; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Dcp.Model; +using k8s; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Dashboard; + +/// +/// Pulls data about resources from DCP's kubernetes API. Streams updates to consumers. +/// +internal sealed class DcpDataSource +{ + private readonly KubernetesService _kubernetesService; + private readonly DistributedApplicationModel _applicationModel; + private readonly Func _onResourceChanged; + private readonly ILogger _logger; + + private readonly Dictionary _containersMap = []; + private readonly Dictionary _executablesMap = []; + private readonly Dictionary _servicesMap = []; + private readonly Dictionary _endpointsMap = []; + private readonly Dictionary<(string, string), List> _resourceAssociatedServicesMap = []; + + public DcpDataSource( + KubernetesService kubernetesService, + DistributedApplicationModel applicationModel, + ILoggerFactory loggerFactory, + Func onResourceChanged, + CancellationToken cancellationToken) + { + _kubernetesService = kubernetesService; + _applicationModel = applicationModel; + _onResourceChanged = onResourceChanged; + + _logger = loggerFactory.CreateLogger(); + + var semaphore = new SemaphoreSlim(1); + + Task.Run( + async () => + { + 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); + } + }, + cancellationToken); + + async Task WatchKubernetesResource(Func handler) where T : CustomResource + { + try + { + await foreach (var (eventType, resource) in _kubernetesService.WatchAsync(cancellationToken: cancellationToken)) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + await handler(eventType, resource).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Watch task over kubernetes resource of type: {resourceType} terminated", typeof(T).Name); + } + } + } + + private async Task ProcessResourceChange(WatchEventType watchEventType, T resource, Dictionary resourceByName, string resourceKind, Func snapshotFactory) where T : CustomResource + { + if (ProcessResourceChange(resourceByName, watchEventType, resource)) + { + UpdateAssociatedServicesMap(resourceKind, watchEventType, resource); + + var changeType = ToChangeType(watchEventType); + var snapshot = snapshotFactory(resource); + + await _onResourceChanged(snapshot, changeType).ConfigureAwait(false); + } + } + + private async Task ProcessEndpointChange(WatchEventType watchEventType, Endpoint endpoint) + { + if (!ProcessResourceChange(_endpointsMap, watchEventType, endpoint)) + { + return; + } + + if (endpoint.Metadata.OwnerReferences is null) + { + return; + } + + foreach (var ownerReference in endpoint.Metadata.OwnerReferences) + { + await TryRefreshResource(ownerReference.Kind, ownerReference.Name).ConfigureAwait(false); + } + } + + private async Task ProcessServiceChange(WatchEventType watchEventType, Service service) + { + if (!ProcessResourceChange(_servicesMap, watchEventType, service)) + { + return; + } + + foreach (var ((resourceKind, resourceName), _) in _resourceAssociatedServicesMap.Where(e => e.Value.Contains(service.Metadata.Name))) + { + await TryRefreshResource(resourceKind, resourceName).ConfigureAwait(false); + } + } + + private async ValueTask TryRefreshResource(string resourceKind, string resourceName) + { + ResourceViewModel? snapshot = resourceKind switch + { + "Container" => _containersMap.TryGetValue(resourceName, out var container) ? ToSnapshot(container) : null, + "Executable" => _executablesMap.TryGetValue(resourceName, out var executable) ? ToSnapshot(executable) : null, + _ => null + }; + + if (snapshot is not null) + { + await _onResourceChanged(snapshot, ResourceChangeType.Upsert).ConfigureAwait(false); + } + } + + private ContainerViewModel ToSnapshot(Container container) + { + var containerId = container.Status?.ContainerId; + var (endpoints, services) = GetEndpointsAndServices(container, "Container"); + + var environment = GetEnvironmentVariables(container.Status?.EffectiveEnv ?? container.Spec.Env, container.Spec.Env); + + return new ContainerViewModel + { + Name = container.Metadata.Name, + DisplayName = container.Metadata.Name, + Uid = container.Metadata.Uid, + ContainerId = containerId, + CreationTimeStamp = container.Metadata.CreationTimestamp?.ToLocalTime(), + Image = container.Spec.Image!, + LogSource = new DockerContainerLogSource(containerId!), + State = container.Status?.State, + ExpectedEndpointsCount = GetExpectedEndpointsCount(container), + Environment = environment, + Endpoints = endpoints, + Services = services, + Command = container.Spec.Command, + Args = container.Spec.Args?.ToImmutableArray() ?? [], + Ports = GetPorts() + }; + + ImmutableArray GetPorts() + { + if (container.Spec.Ports is null) + { + return []; + } + + var ports = ImmutableArray.CreateBuilder(); + foreach (var port in container.Spec.Ports) + { + if (port.ContainerPort != null) + { + ports.Add(port.ContainerPort.Value); + } + } + return ports.ToImmutable(); + } + } + + private ExecutableViewModel ToSnapshot(Executable executable) + { + string? projectPath = null; + executable.Metadata.Annotations?.TryGetValue(Executable.CSharpProjectPathAnnotation, out projectPath); + + var (endpoints, services) = GetEndpointsAndServices(executable, "Executable", projectPath); + + if (projectPath is not null) + { + // This executable represents a C# project, so we create a slightly different type here + // that captures the project's path, making it more convenient for consumers to work with + // the project. + return new ProjectViewModel + { + Name = executable.Metadata.Name, + DisplayName = ComputeExecutableDisplayName(executable), + Uid = executable.Metadata.Uid, + CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToLocalTime(), + ExecutablePath = executable.Spec.ExecutablePath, + WorkingDirectory = executable.Spec.WorkingDirectory, + Arguments = executable.Spec.Args?.ToImmutableArray(), + ProjectPath = projectPath, + State = executable.Status?.State, + LogSource = new FileLogSource(executable.Status?.StdOutFile, executable.Status?.StdErrFile), + ProcessId = executable.Status?.ProcessId, + ExpectedEndpointsCount = GetExpectedEndpointsCount(executable), + Environment = GetEnvironmentVariables(executable.Status?.EffectiveEnv, executable.Spec.Env), + Endpoints = endpoints, + Services = services + }; + } + + return new ExecutableViewModel + { + Name = executable.Metadata.Name, + DisplayName = ComputeExecutableDisplayName(executable), + Uid = executable.Metadata.Uid, + CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToLocalTime(), + ExecutablePath = executable.Spec.ExecutablePath, + WorkingDirectory = executable.Spec.WorkingDirectory, + Arguments = executable.Spec.Args?.ToImmutableArray(), + State = executable.Status?.State, + LogSource = new FileLogSource(executable.Status?.StdOutFile, executable.Status?.StdErrFile), + ProcessId = executable.Status?.ProcessId, + ExpectedEndpointsCount = GetExpectedEndpointsCount(executable), + Environment = GetEnvironmentVariables(executable.Status?.EffectiveEnv, executable.Spec.Env), + Endpoints = endpoints, + Services = services + }; + } + + private (ImmutableArray Endpoints, ImmutableArray Services) GetEndpointsAndServices( + CustomResource resource, + string resourceKind, + string? projectPath = null) + { + var endpoints = ImmutableArray.CreateBuilder(); + var services = ImmutableArray.CreateBuilder(); + var name = resource.Metadata.Name; + + foreach (var endpoint in _endpointsMap.Values) + { + if (endpoint.Metadata.OwnerReferences?.Any(or => or.Kind == resource.Kind && or.Name == name) != true) + { + continue; + } + + if (endpoint.Spec.ServiceName is not null + && _servicesMap.TryGetValue(endpoint.Spec.ServiceName, out var service) + && service?.UsesHttpProtocol(out var uriScheme) == true) + { + var endpointString = $"{uriScheme}://{endpoint.Spec.Address}:{endpoint.Spec.Port}"; + + // For project look into launch profile to append launch url + if (projectPath is not null + && _applicationModel.TryGetProjectWithPath(name, projectPath, out var project) + && project.GetEffectiveLaunchProfile() is LaunchProfile launchProfile + && launchProfile.LaunchUrl is string launchUrl) + { + if (!launchUrl.Contains("://")) + { + // This is relative URL + endpointString += $"/{launchUrl}"; + } + else + { + // For absolute URL we need to update the port value if possible + if (launchProfile.ApplicationUrl is string applicationUrl + && launchUrl.StartsWith(applicationUrl)) + { + endpointString = launchUrl.Replace(applicationUrl, endpointString); + } + } + + // If we cannot process launchUrl then we just show endpoint string + } + + endpoints.Add(endpointString); + } + } + + if (_resourceAssociatedServicesMap.TryGetValue((resourceKind, name), out var resourceServiceMappings)) + { + foreach (var serviceName in resourceServiceMappings) + { + if (_servicesMap.TryGetValue(name, out var service)) + { + services.Add(new ResourceServiceSnapshot(service.Metadata.Name, service.AllocatedAddress, service.AllocatedPort)); + } + } + } + + return (endpoints.ToImmutable(), services.ToImmutable()); + } + + private int? GetExpectedEndpointsCount(CustomResource resource) + { + var expectedCount = 0; + if (resource.Metadata.Annotations?.TryGetValue(CustomResource.ServiceProducerAnnotation, out var servicesProducedAnnotationJson) == true) + { + var serviceProducerAnnotations = JsonSerializer.Deserialize(servicesProducedAnnotationJson); + if (serviceProducerAnnotations is not null) + { + foreach (var serviceProducer in serviceProducerAnnotations) + { + if (!_servicesMap.TryGetValue(serviceProducer.ServiceName, out var service)) + { + // We don't have matching service so we cannot compute endpoint count completely + // So we return null indicating that it is unknown. + // Dashboard should show this as Starting + return null; + } + + if (service.UsesHttpProtocol(out _)) + { + expectedCount++; + } + } + } + } + + return expectedCount; + } + + private static ImmutableArray GetEnvironmentVariables(List? effectiveSource, List? specSource) + { + if (effectiveSource is null or { Count: 0 }) + { + return []; + } + + var environment = ImmutableArray.CreateBuilder(effectiveSource.Count); + + foreach (var env in effectiveSource) + { + if (env.Name is not null) + { + environment.Add(new() + { + Name = env.Name, + Value = env.Value, + FromSpec = specSource?.Any(e => string.Equals(e.Name, env.Name, StringComparison.Ordinal)) is true or null + }); + } + } + + environment.Sort((v1, v2) => string.Compare(v1.Name, v2.Name, StringComparison.Ordinal)); + + return environment.ToImmutable(); + } + + private void UpdateAssociatedServicesMap(string resourceKind, WatchEventType watchEventType, CustomResource resource) + { + // We keep track of associated services for the resource + // So whenever we get the service we can figure out if the service can generate endpoint for the resource + if (watchEventType == WatchEventType.Deleted) + { + _resourceAssociatedServicesMap.Remove((resourceKind, resource.Metadata.Name)); + } + else if (resource.Metadata.Annotations?.TryGetValue(CustomResource.ServiceProducerAnnotation, out var servicesProducedAnnotationJson) == true) + { + var serviceProducerAnnotations = JsonSerializer.Deserialize(servicesProducedAnnotationJson); + if (serviceProducerAnnotations is not null) + { + _resourceAssociatedServicesMap[(resourceKind, resource.Metadata.Name)] + = serviceProducerAnnotations.Select(e => e.ServiceName).ToList(); + } + } + } + + private static bool ProcessResourceChange(Dictionary map, WatchEventType watchEventType, T resource) + where T : CustomResource + { + switch (watchEventType) + { + case WatchEventType.Added: + map.Add(resource.Metadata.Name, resource); + break; + + case WatchEventType.Modified: + map[resource.Metadata.Name] = resource; + break; + + case WatchEventType.Deleted: + map.Remove(resource.Metadata.Name); + break; + + default: + return false; + } + + return true; + } + + private static ResourceChangeType ToChangeType(WatchEventType watchEventType) + { + return watchEventType switch + { + WatchEventType.Added or WatchEventType.Modified => ResourceChangeType.Upsert, + WatchEventType.Deleted => ResourceChangeType.Deleted, + _ => ResourceChangeType.Other + }; + } + + private static string ComputeExecutableDisplayName(Executable executable) + { + var displayName = executable.Metadata.Name; + var replicaSetOwner = executable.Metadata.OwnerReferences?.FirstOrDefault( + or => or.Kind == Dcp.Model.Dcp.ExecutableReplicaSetKind + ); + if (replicaSetOwner is not null && displayName.Length > 3) + { + var nameParts = displayName.Split('-'); + if (nameParts.Length == 2 && nameParts[0].Length > 0 && nameParts[1].Length > 0) + { + // Strip the replica ID from the name. + displayName = nameParts[0]; + } + } + return displayName; + } +} diff --git a/src/Aspire.Hosting/Dashboard/ResourceCollection.cs b/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs similarity index 56% rename from src/Aspire.Hosting/Dashboard/ResourceCollection.cs rename to src/Aspire.Hosting/Dashboard/ResourcePublisher.cs index 9aa447ccd3..60c096d0f0 100644 --- a/src/Aspire.Hosting/Dashboard/ResourceCollection.cs +++ b/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs @@ -8,26 +8,16 @@ namespace Aspire.Hosting.Dashboard; /// -/// Builds a collection of resources by integrating incoming changes from a channel, -/// and allowing multiple subscribers to receive the current resource snapshot and future -/// updates. +/// Builds a collection of resources by integrating incoming resource changes, +/// and allowing multiple subscribers to receive the current resource collection +/// snapshot and future updates. /// -internal sealed class ResourceCollection +internal sealed class ResourcePublisher(CancellationToken cancellationToken) { private readonly object _syncLock = new(); - private readonly Channel _incomingChannel; - private readonly CancellationToken _cancellationToken; private readonly Dictionary _snapshot = []; private ImmutableHashSet> _outgoingChannels = []; - public ResourceCollection(Channel incomingChannel, CancellationToken cancellationToken) - { - _incomingChannel = incomingChannel; - _cancellationToken = cancellationToken; - - Task.Run(ProcessChanges, cancellationToken); - } - public ResourceSubscription Subscribe() { lock (_syncLock) @@ -38,7 +28,7 @@ public ResourceSubscription Subscribe() return new ResourceSubscription( Snapshot: _snapshot.Values.ToList(), - Subscription: new ChangeEnumerable(channel, RemoveChannel)); + Subscription: new ResourceSubscriptionEnumerable(channel, disposeAction: RemoveChannel)); } void RemoveChannel(Channel channel) @@ -47,12 +37,40 @@ void RemoveChannel(Channel channel) } } - private sealed class ChangeEnumerable : IAsyncEnumerable + /// + /// Integrates a changed resource within the cache, and broadcasts the update to any subscribers. + /// + /// The resource that was modified. + /// The change type (Added, Modified, Deleted). + /// A task that completes when the cache has been updated and all subscribers notified. + public async ValueTask Integrate(ResourceViewModel resource, ResourceChangeType changeType) + { + lock (_syncLock) + { + switch (changeType) + { + case ResourceChangeType.Upsert: + _snapshot[resource.Name] = resource; + break; + + case ResourceChangeType.Deleted: + _snapshot.Remove(resource.Name); + break; + } + } + + foreach (var channel in _outgoingChannels) + { + await channel.Writer.WriteAsync(new(changeType, resource), cancellationToken).ConfigureAwait(false); + } + } + + private sealed class ResourceSubscriptionEnumerable : IAsyncEnumerable { private readonly Channel _channel; private readonly Action> _disposeAction; - public ChangeEnumerable(Channel channel, Action> disposeAction) + public ResourceSubscriptionEnumerable(Channel channel, Action> disposeAction) { _channel = channel; _disposeAction = disposeAction; @@ -60,17 +78,17 @@ public ChangeEnumerable(Channel channel, Action GetAsyncEnumerator(CancellationToken cancellationToken = default) { - return new ChangeEnumerator(_channel, _disposeAction, cancellationToken); + return new ResourceSubscriptionEnumerator(_channel, _disposeAction, cancellationToken); } } - private sealed class ChangeEnumerator : IAsyncEnumerator + private sealed class ResourceSubscriptionEnumerator : IAsyncEnumerator { private readonly Channel _channel; private readonly Action> _disposeAction; private readonly CancellationToken _cancellationToken; - public ChangeEnumerator( + public ResourceSubscriptionEnumerator( Channel channel, Action> disposeAction, CancellationToken cancellationToken) { _channel = channel; @@ -95,35 +113,4 @@ public async ValueTask MoveNextAsync() return true; } } - - private async Task ProcessChanges() - { - await foreach (var change in _incomingChannel.Reader.ReadAllAsync(_cancellationToken)) - { - var (changeType, resource) = change; - - lock (_syncLock) - { - switch (changeType) - { - case ObjectChangeType.Added: - _snapshot.Add(resource.Name, resource); - break; - - case ObjectChangeType.Modified: - _snapshot[resource.Name] = resource; - break; - - case ObjectChangeType.Deleted: - _snapshot.Remove(resource.Name); - break; - } - } - - foreach (var channel in _outgoingChannels) - { - await channel.Writer.WriteAsync(change, _cancellationToken).ConfigureAwait(false); - } - } - } } diff --git a/src/Aspire.Hosting/Dashboard/ResourceService.cs b/src/Aspire.Hosting/Dashboard/ResourceService.cs index e06b12ef50..badf808f6a 100644 --- a/src/Aspire.Hosting/Dashboard/ResourceService.cs +++ b/src/Aspire.Hosting/Dashboard/ResourceService.cs @@ -1,19 +1,9 @@ // 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.Diagnostics; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; -using Aspire.Hosting.Dcp.Model; -using Aspire.Hosting.Dcp.Process; -using Aspire.Hosting.Utils; -using k8s; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,47 +11,17 @@ namespace Aspire.Hosting.Dashboard; internal sealed partial class ResourceService : IResourceService, IAsyncDisposable { - private readonly KubernetesService _kubernetesService; - private readonly DistributedApplicationModel _applicationModel; - private readonly ILogger _logger; - private readonly CancellationTokenSource _cancellationTokenSource = new(); - private readonly CancellationToken _cancellationToken; - - // Private channels, for decoupling producer/consumer and serialising updates. - private readonly Channel<(WatchEventType, string, CustomResource?)> _kubernetesChangesChannel; - private readonly Channel _resourceChannel; - - private readonly Dictionary _containersMap = []; - private readonly Dictionary _executablesMap = []; - private readonly Dictionary _servicesMap = []; - private readonly Dictionary _endpointsMap = []; - private readonly Dictionary<(ResourceKind, string), List> _resourceAssociatedServicesMap = []; - private readonly ConcurrentDictionary> _additionalEnvVarsMap = []; - private readonly HashSet _containersWithTaskStarted = []; - - private readonly ResourceCollection _resourceCollection; + private readonly ResourcePublisher _resourcePublisher; public ResourceService( DistributedApplicationModel applicationModel, KubernetesService kubernetesService, IHostEnvironment hostEnvironment, ILoggerFactory loggerFactory) { - _applicationModel = applicationModel; - _kubernetesService = kubernetesService; ApplicationName = ComputeApplicationName(hostEnvironment.ApplicationName); - _logger = loggerFactory.CreateLogger(); - _cancellationToken = _cancellationTokenSource.Token; - _kubernetesChangesChannel = Channel.CreateUnbounded<(WatchEventType, string, CustomResource?)>(); - _resourceChannel = Channel.CreateUnbounded(); + _resourcePublisher = new ResourcePublisher(_cancellationTokenSource.Token); - RunWatchTask(); - RunWatchTask(); - RunWatchTask(); - RunWatchTask(); - - Task.Run(ProcessKubernetesChanges); - - _resourceCollection = new ResourceCollection(_resourceChannel, _cancellationToken); + _ = new DcpDataSource(kubernetesService, applicationModel, loggerFactory, _resourcePublisher.Integrate, _cancellationTokenSource.Token); static string ComputeApplicationName(string applicationName) { @@ -78,538 +38,7 @@ static string ComputeApplicationName(string applicationName) public string ApplicationName { get; } - public ResourceSubscription Subscribe() => _resourceCollection.Subscribe(); - - private void RunWatchTask() - where T : CustomResource - { - _ = Task.Run(async () => - { - try - { - await foreach (var (eventType, resource) in _kubernetesService.WatchAsync(cancellationToken: _cancellationToken)) - { - await _kubernetesChangesChannel.Writer.WriteAsync( - (eventType, resource.Metadata.Name, resource), _cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, "Watch task over kubernetes resource of type: {resourceType} terminated", typeof(T).Name); - } - }); - } - - private async Task ProcessKubernetesChanges() - { - try - { - await foreach (var (watchEventType, name, resource) in _kubernetesChangesChannel.Reader.ReadAllAsync(_cancellationToken)) - { - // resource is null when we get notification from the task which fetch docker env vars - // So we inject the resource from current copy of containersMap - // But this could change in future - Debug.Assert(resource is not null || _containersMap.ContainsKey(name), - "Received a change notification with null resource which doesn't correlate to existing container."); - - switch (resource ?? _containersMap[name]) - { - case Container container: - await ProcessContainerChange(watchEventType, container).ConfigureAwait(false); - break; - - case Executable executable - when !executable.IsCSharpProject(): - await ProcessExecutableChange(watchEventType, executable).ConfigureAwait(false); - break; - - case Executable executable - when executable.IsCSharpProject(): - await ProcessProjectChange(watchEventType, executable).ConfigureAwait(false); - break; - - case Endpoint endpoint: - await ProcessEndpointChange(watchEventType, endpoint).ConfigureAwait(false); - break; - - case Service service: - await ProcessServiceChange(watchEventType, service).ConfigureAwait(false); - break; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, "Task to compute resource changes terminated"); - } - } - - private async Task ProcessContainerChange(WatchEventType watchEventType, Container container) - { - if (!ProcessResourceChange(_containersMap, watchEventType, container)) - { - return; - } - - UpdateAssociatedServicesMap(ResourceKind.Container, watchEventType, container); - List? extraEnvVars = null; - if (container.Status?.ContainerId is string containerId - && !_additionalEnvVarsMap.TryGetValue(containerId, out extraEnvVars) - && !_containersWithTaskStarted.Contains(containerId)) - { - // Container is ready to be inspected - // This task when returns will generate a notification in channel - _ = Task.Run(() => ComputeEnvironmentVariablesFromDocker(containerId, container.Metadata.Name)); - _containersWithTaskStarted.Add(containerId); - } - - var objectChangeType = ToObjectChangeType(watchEventType); - - var containerViewModel = ConvertToContainerViewModel(container, extraEnvVars); - - await WriteChange(containerViewModel, objectChangeType).ConfigureAwait(false); - } - - private async Task ProcessExecutableChange(WatchEventType watchEventType, Executable executable) - { - if (!ProcessResourceChange(_executablesMap, watchEventType, executable)) - { - return; - } - - UpdateAssociatedServicesMap(ResourceKind.Executable, watchEventType, executable); - - var objectChangeType = ToObjectChangeType(watchEventType); - var executableViewModel = ConvertToExecutableViewModel(executable); - - await WriteChange(executableViewModel, objectChangeType).ConfigureAwait(false); - } - - private async Task ProcessProjectChange(WatchEventType watchEventType, Executable executable) - { - if (!ProcessResourceChange(_executablesMap, watchEventType, executable)) - { - return; - } - - UpdateAssociatedServicesMap(ResourceKind.Executable, watchEventType, executable); - - var objectChangeType = ToObjectChangeType(watchEventType); - var projectViewModel = ConvertToProjectViewModel(executable); - - await WriteChange(projectViewModel, objectChangeType).ConfigureAwait(false); - } - - private async Task ProcessEndpointChange(WatchEventType watchEventType, Endpoint endpoint) - { - if (!ProcessResourceChange(_endpointsMap, watchEventType, endpoint)) - { - return; - } - - if (endpoint.Metadata.OwnerReferences is null) - { - return; - } - - foreach (var ownerReference in endpoint.Metadata.OwnerReferences) - { - // Find better way for this string switch - switch (ownerReference.Kind) - { - case "Container": - if (_containersMap.TryGetValue(ownerReference.Name, out var container)) - { - var extraEnvVars = GetContainerEnvVars(container.Status?.ContainerId); - var containerViewModel = ConvertToContainerViewModel(container, extraEnvVars); - - await WriteChange(containerViewModel).ConfigureAwait(false); - } - break; - - case "Executable": - if (_executablesMap.TryGetValue(ownerReference.Name, out var executable)) - { - if (executable.IsCSharpProject()) - { - // Project - var projectViewModel = ConvertToProjectViewModel(executable); - - await WriteChange(projectViewModel).ConfigureAwait(false); - } - else - { - // Executable - var executableViewModel = ConvertToExecutableViewModel(executable); - - await WriteChange(executableViewModel).ConfigureAwait(false); - } - } - break; - } - } - } - - private async Task ProcessServiceChange(WatchEventType watchEventType, Service service) - { - if (!ProcessResourceChange(_servicesMap, watchEventType, service)) - { - return; - } - - foreach (var ((resourceKind, resourceName), _) in _resourceAssociatedServicesMap.Where(e => e.Value.Contains(service.Metadata.Name))) - { - switch (resourceKind) - { - case ResourceKind.Container: - if (_containersMap.TryGetValue(resourceName, out var container)) - { - var extraEnvVars = GetContainerEnvVars(container.Status?.ContainerId); - var containerViewModel = ConvertToContainerViewModel(container, extraEnvVars); - - await WriteChange(containerViewModel).ConfigureAwait(false); - } - break; - - case ResourceKind.Executable: - if (_executablesMap.TryGetValue(resourceName, out var executable)) - { - if (executable.IsCSharpProject()) - { - // Project - var projectViewModel = ConvertToProjectViewModel(executable); - - await WriteChange(projectViewModel).ConfigureAwait(false); - } - else - { - // Executable - var executableViewModel = ConvertToExecutableViewModel(executable); - - await WriteChange(executableViewModel).ConfigureAwait(false); - } - } - break; - } - } - } - - private async Task WriteChange(ResourceViewModel resourceViewModel, ObjectChangeType changeType = ObjectChangeType.Modified) - { - await _resourceChannel.Writer.WriteAsync( - new ResourceChange(changeType, resourceViewModel), _cancellationToken) - .ConfigureAwait(false); - } - - private ContainerViewModel ConvertToContainerViewModel(Container container, List? additionalEnvVars) - { - var model = new ContainerViewModel - { - Name = container.Metadata.Name, - DisplayName = container.Metadata.Name, - Uid = container.Metadata.Uid, - ContainerId = container.Status?.ContainerId, - CreationTimeStamp = container.Metadata.CreationTimestamp?.ToLocalTime(), - Image = container.Spec.Image!, - LogSource = new DockerContainerLogSource(container.Status!.ContainerId!), - State = container.Status?.State, - ExpectedEndpointsCount = GetExpectedEndpointsCount(_servicesMap.Values, container), - Command = container.Spec.Command, - Args = container.Spec.Args - }; - - if (container.Spec.Ports != null) - { - foreach (var port in container.Spec.Ports) - { - if (port.ContainerPort != null) - { - model.Ports.Add(port.ContainerPort.Value); - } - } - } - - FillEndpoints(container, model, ResourceKind.Container); - - if (additionalEnvVars is not null) - { - FillEnvironmentVariables(model.Environment, additionalEnvVars, additionalEnvVars); - } - else if (container.Spec.Env is not null) - { - FillEnvironmentVariables(model.Environment, container.Spec.Env, container.Spec.Env); - } - - return model; - } - - private ExecutableViewModel ConvertToExecutableViewModel(Executable executable) - { - var model = new ExecutableViewModel - { - Name = executable.Metadata.Name, - DisplayName = ComputeExecutableDisplayName(executable), - Uid = executable.Metadata.Uid, - CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToLocalTime(), - ExecutablePath = executable.Spec.ExecutablePath, - WorkingDirectory = executable.Spec.WorkingDirectory, - Arguments = executable.Spec.Args, - State = executable.Status?.State, - LogSource = new FileLogSource(executable.Status?.StdOutFile, executable.Status?.StdErrFile), - ProcessId = executable.Status?.ProcessId, - ExpectedEndpointsCount = GetExpectedEndpointsCount(_servicesMap.Values, executable) - }; - - FillEndpoints(executable, model, ResourceKind.Executable); - - if (executable.Status?.EffectiveEnv is not null) - { - FillEnvironmentVariables(model.Environment, executable.Status.EffectiveEnv, executable.Spec.Env); - } - return model; - } - - private ProjectViewModel ConvertToProjectViewModel(Executable executable) - { - var model = new ProjectViewModel - { - Name = executable.Metadata.Name, - DisplayName = ComputeExecutableDisplayName(executable), - Uid = executable.Metadata.Uid, - CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToLocalTime(), - ProjectPath = executable.Metadata.Annotations?[Executable.CSharpProjectPathAnnotation] ?? "", - State = executable.Status?.State, - LogSource = new FileLogSource(executable.Status?.StdOutFile, executable.Status?.StdErrFile), - ProcessId = executable.Status?.ProcessId, - ExpectedEndpointsCount = GetExpectedEndpointsCount(_servicesMap.Values, executable) - }; - - FillEndpoints(executable, model, ResourceKind.Executable); - - if (executable.Status?.EffectiveEnv is not null) - { - FillEnvironmentVariables(model.Environment, executable.Status.EffectiveEnv, executable.Spec.Env); - } - return model; - } - - private void FillEndpoints( - CustomResource resource, - ResourceViewModel resourceViewModel, - ResourceKind resourceKind) - { - foreach (var endpoint in _endpointsMap.Values) - { - if (endpoint.Metadata.OwnerReferences?.Any(or => or.Kind == resource.Kind && or.Name == resource.Metadata.Name) != true) - { - continue; - } - - var matchingService = _servicesMap.Values.SingleOrDefault(s => s.Metadata.Name == endpoint.Spec.ServiceName); - if (matchingService?.UsesHttpProtocol(out var uriScheme) == true) - { - var endpointString = $"{uriScheme}://{endpoint.Spec.Address}:{endpoint.Spec.Port}"; - - // For project look into launch profile to append launch url - if (resourceViewModel is ProjectViewModel projectViewModel - && _applicationModel.TryGetProjectWithPath(projectViewModel.Name, projectViewModel.ProjectPath, out var project) - && project.GetEffectiveLaunchProfile() is LaunchProfile launchProfile - && launchProfile.LaunchUrl is string launchUrl) - { - if (!launchUrl.Contains("://")) - { - // This is relative URL - endpointString += $"/{launchUrl}"; - } - else - { - // For absolute URL we need to update the port value if possible - if (launchProfile.ApplicationUrl is string applicationUrl - && launchUrl.StartsWith(applicationUrl)) - { - endpointString = launchUrl.Replace(applicationUrl, endpointString); - } - } - - // If we cannot process launchUrl then we just show endpoint string - } - - resourceViewModel.Endpoints.Add(endpointString); - } - } - - if (_resourceAssociatedServicesMap.TryGetValue((resourceKind, resourceViewModel.Name), out var resourceServiceMappings)) - { - foreach (var serviceName in resourceServiceMappings) - { - var service = _servicesMap.Values.FirstOrDefault(s => s.Metadata.Name == resourceViewModel.Name); - if (service != null) - { - resourceViewModel.Services.Add(new Aspire.Dashboard.Model.ResourceService(service.Metadata.Name, service.AllocatedAddress, service.AllocatedPort)); - } - } - } - } - - private static int? GetExpectedEndpointsCount(IEnumerable services, CustomResource resource) - { - var expectedCount = 0; - if (resource.Metadata.Annotations?.TryGetValue(CustomResource.ServiceProducerAnnotation, out var servicesProducedAnnotationJson) == true) - { - var serviceProducerAnnotations = JsonSerializer.Deserialize(servicesProducedAnnotationJson); - if (serviceProducerAnnotations is not null) - { - foreach (var serviceProducer in serviceProducerAnnotations) - { - var matchingService = services.SingleOrDefault(s => s.Metadata.Name == serviceProducer.ServiceName); - if (matchingService is null) - { - // We don't have matching service so we cannot compute endpoint count completely - // So we return null indicating that it is unknown. - // Dashboard should show this as Starting - return null; - } - - if (matchingService.UsesHttpProtocol(out _)) - { - expectedCount++; - } - } - } - } - - return expectedCount; - } - - private static void FillEnvironmentVariables(List target, List effectiveSource, List? specSource) - { - foreach (var env in effectiveSource) - { - if (env.Name is not null) - { - target.Add(new() - { - Name = env.Name, - Value = env.Value, - FromSpec = specSource?.Any(e => string.Equals(e.Name, env.Name, StringComparison.Ordinal)) == true - }); - } - } - - target.Sort((v1, v2) => string.Compare(v1.Name, v2.Name)); - } - - private void UpdateAssociatedServicesMap(ResourceKind resourceKind, WatchEventType watchEventType, CustomResource resource) - { - // We keep track of associated services for the resource - // So whenever we get the service we can figure out if the service can generate endpoint for the resource - if (watchEventType == WatchEventType.Deleted) - { - _resourceAssociatedServicesMap.Remove((resourceKind, resource.Metadata.Name)); - } - else if (resource.Metadata.Annotations?.TryGetValue(CustomResource.ServiceProducerAnnotation, out var servicesProducedAnnotationJson) == true) - { - var serviceProducerAnnotations = JsonSerializer.Deserialize(servicesProducedAnnotationJson); - if (serviceProducerAnnotations is not null) - { - _resourceAssociatedServicesMap[(resourceKind, resource.Metadata.Name)] - = serviceProducerAnnotations.Select(e => e.ServiceName).ToList(); - } - } - } - - private async Task ComputeEnvironmentVariablesFromDocker(string containerId, string name) - { - IAsyncDisposable? processDisposable = null; - try - { - Task task; - var outputStringBuilder = new StringBuilder(); - var spec = new ProcessSpec(FileUtil.FindFullPathFromPath("docker")) - { - Arguments = $"container inspect --format=\"{{{{json .Config.Env}}}}\" {containerId}", - OnOutputData = s => outputStringBuilder.Append(s), - KillEntireProcessTree = false, - ThrowOnNonZeroReturnCode = false - }; - - (task, processDisposable) = ProcessUtil.Run(spec); - - var exitCode = (await task.WaitAsync(TimeSpan.FromSeconds(30), _cancellationToken).ConfigureAwait(false)).ExitCode; - if (exitCode == 0) - { - var output = outputStringBuilder.ToString(); - if (output == string.Empty) - { - return; - } - - var jsonArray = JsonNode.Parse(output)?.AsArray(); - if (jsonArray is not null) - { - var envVars = new List(); - foreach (var item in jsonArray) - { - if (item is not null) - { - var parts = item.ToString().Split('=', 2); - envVars.Add(new EnvVar { Name = parts[0], Value = parts[1] }); - } - } - - _additionalEnvVarsMap[containerId] = envVars; - await _kubernetesChangesChannel.Writer.WriteAsync((WatchEventType.Modified, name, null), _cancellationToken).ConfigureAwait(false); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, "Failed to retrieve environment variables from docker container for {containerId}", containerId); - } - finally - { - if (processDisposable != null) - { - await processDisposable.DisposeAsync().ConfigureAwait(false); - } - } - } - - private List? GetContainerEnvVars(string? containerId) - => containerId is not null && _additionalEnvVarsMap.TryGetValue(containerId, out var envVars) ? envVars : null; - - private static bool ProcessResourceChange(Dictionary map, WatchEventType watchEventType, T resource) - where T : CustomResource - { - switch (watchEventType) - { - case WatchEventType.Added: - map.Add(resource.Metadata.Name, resource); - break; - - case WatchEventType.Modified: - map[resource.Metadata.Name] = resource; - break; - - case WatchEventType.Deleted: - map.Remove(resource.Metadata.Name); - break; - - default: - return false; - } - - return true; - } - - private static ObjectChangeType ToObjectChangeType(WatchEventType watchEventType) - => watchEventType switch - { - WatchEventType.Added => ObjectChangeType.Added, - WatchEventType.Modified => ObjectChangeType.Modified, - WatchEventType.Deleted => ObjectChangeType.Deleted, - _ => ObjectChangeType.Other - }; + public ResourceSubscription Subscribe() => _resourcePublisher.Subscribe(); public async ValueTask DisposeAsync() { @@ -620,28 +49,4 @@ public async ValueTask DisposeAsync() await _cancellationTokenSource.CancelAsync().ConfigureAwait(false); } - - private static string ComputeExecutableDisplayName(Executable executable) - { - var displayName = executable.Metadata.Name; - var replicaSetOwner = executable.Metadata.OwnerReferences?.FirstOrDefault( - or => or.Kind == Dcp.Model.Dcp.ExecutableReplicaSetKind - ); - if (replicaSetOwner is not null && displayName.Length > 3) - { - var nameParts = displayName.Split('-'); - if (nameParts.Length == 2 && nameParts[0].Length > 0 && nameParts[1].Length > 0) - { - // Strip the replica ID from the name. - displayName = nameParts[0]; - } - } - return displayName; - } - - private enum ResourceKind - { - Container, - Executable - } } diff --git a/src/Aspire.Hosting/Dcp/DcpHostService.cs b/src/Aspire.Hosting/Dcp/DcpHostService.cs index e06ff113f1..7029583cf1 100644 --- a/src/Aspire.Hosting/Dcp/DcpHostService.cs +++ b/src/Aspire.Hosting/Dcp/DcpHostService.cs @@ -12,6 +12,7 @@ using Aspire.Dashboard; using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Properties; using Aspire.Hosting.Publishing; @@ -20,7 +21,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using ResourceService = Aspire.Hosting.Dashboard.ResourceService; namespace Aspire.Hosting.Dcp; diff --git a/src/Aspire.Hosting/Dcp/Model/Container.cs b/src/Aspire.Hosting/Dcp/Model/Container.cs index f5b8662899..49fd44ac7e 100644 --- a/src/Aspire.Hosting/Dcp/Model/Container.cs +++ b/src/Aspire.Hosting/Dcp/Model/Container.cs @@ -174,6 +174,10 @@ internal sealed class ContainerStatus : V1Status [JsonPropertyName("exitCode")] public int ExitCode { get; set; } = Conventions.UnknownExitCode; + // Effective values of environment variables, after all substitutions have been applied + [JsonPropertyName("effectiveEnv")] + public List? EffectiveEnv { get; set; } + // Note: the ContainerStatus has "Message" property that represents a human-readable information about Container state. // It is provided by V1Status base class. } diff --git a/src/Aspire.Hosting/Dcp/Model/Executable.cs b/src/Aspire.Hosting/Dcp/Model/Executable.cs index 038aad37b6..7c8387ac08 100644 --- a/src/Aspire.Hosting/Dcp/Model/Executable.cs +++ b/src/Aspire.Hosting/Dcp/Model/Executable.cs @@ -116,8 +116,6 @@ internal sealed class Executable : CustomResource Metadata.Annotations?.ContainsKey(CSharpProjectPathAnnotation) == true; - public static Executable Create(string name, string executablePath) { var exe = new Executable(new ExecutableSpec diff --git a/src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs b/src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs index 3c49fe0fc4..a31efc133f 100644 --- a/src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs +++ b/src/Aspire.Hosting/Dcp/Process/ProcessUtil.cs @@ -34,7 +34,10 @@ public static (Task, IAsyncDisposable) Run(ProcessSpec processSpe EnableRaisingEvents = true }; - processSpec.EnvironmentVariables.ToList().ForEach(x => process.StartInfo.Environment[x.Key] = x.Value); + foreach (var (key, value) in processSpec.EnvironmentVariables) + { + process.StartInfo.Environment[key] = value; + } var processEventLock = new object(); @@ -109,7 +112,6 @@ public static (Task, IAsyncDisposable) Run(ProcessSpec processSpe { AspireEventSource.Instance.ProcessLaunchStop(processSpec.ExecutablePath, processSpec.Arguments ?? ""); } - return (processLifetimeTcs.Task, new ProcessDisposable(process, processLifetimeTcs.Task, processSpec.KillEntireProcessTree)); }