File: ApplicationModel\TerminalResourceSnapshotProperties.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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.Globalization;
using Aspire.Dashboard.Model;
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// Stamps the per-replica terminal properties (<c>terminal.enabled</c>,
/// <c>terminal.replicaIndex</c>, <c>terminal.replicaCount</c>, and the sensitive
/// <c>terminal.consumerUdsPath</c>) onto a resource snapshot when the resource opted into
/// <see cref="TerminalResourceBuilderExtensions.WithTerminal{T}(IResourceBuilder{T}, Action{TerminalOptions}?)"/>.
/// </summary>
/// <remarks>
/// Both the dashboard gRPC path (<c>DashboardServiceData</c>) and the auxiliary backchannel
/// path (<c>AuxiliaryBackchannelRpcTarget</c>, which feeds <c>aspire describe</c> and the VS Code
/// extension) need the exact same set of terminal properties so consumers can detect terminal
/// availability and target the right replica. Sharing this helper keeps the two emission paths
/// from drifting: previously only the dashboard stamped these properties, so <c>aspire describe</c>
/// never surfaced <c>terminal.enabled</c> and the extension's "Open terminal" action stayed hidden.
/// </remarks>
internal static class TerminalResourceSnapshotProperties
{
    /// <summary>
    /// Returns <paramref name="properties"/> augmented with the terminal properties when
    /// <paramref name="resource"/> carries a <see cref="TerminalAnnotation"/>; otherwise returns
    /// <paramref name="properties"/> unchanged.
    /// </summary>
    /// <param name="resource">The resource whose snapshot is being built.</param>
    /// <param name="resourceId">The per-replica DCP resource id (e.g. <c>myapp-abc123</c>) used to
    /// resolve the stable 0-based replica index.</param>
    /// <param name="properties">The existing snapshot properties to augment.</param>
    public static ImmutableArray<ResourcePropertySnapshot> AddTerminalProperties(
        IResource resource,
        string resourceId,
        ImmutableArray<ResourcePropertySnapshot> properties)
    {
        ArgumentNullException.ThrowIfNull(resource);
        ArgumentNullException.ThrowIfNull(resourceId);
 
        var terminalAnnotation = resource.Annotations.OfType<TerminalAnnotation>().FirstOrDefault();
        if (terminalAnnotation is null)
        {
            return properties;
        }
 
        var terminalHosts = terminalAnnotation.TerminalHosts;
        var replicaCount = terminalHosts.Count;
        var replicaIndex = ResolveReplicaIndex(resource, resourceId);
        var consumerUdsPath = (uint)replicaIndex < (uint)replicaCount
            ? terminalHosts[replicaIndex].Layout.ConsumerUdsPath
            : null;
 
        properties = properties
            .Add(new ResourcePropertySnapshot(KnownProperties.Terminal.Enabled, "true") { IsSensitive = false })
            .Add(new ResourcePropertySnapshot(KnownProperties.Terminal.ReplicaIndex, replicaIndex.ToString(CultureInfo.InvariantCulture)) { IsSensitive = false })
            .Add(new ResourcePropertySnapshot(KnownProperties.Terminal.ReplicaCount, replicaCount.ToString(CultureInfo.InvariantCulture)) { IsSensitive = false });
 
        if (consumerUdsPath is not null)
        {
            // Mark the UDS path sensitive so the dashboard masks it in the resource details panel.
            // The backchannel path redacts sensitive values to null before they reach the CLI, so
            // the consumer socket path is never exposed to `aspire describe` / the extension; the CLI
            // resolves the real path through GetTerminalInfoAsync instead.
            properties = properties.Add(
                new ResourcePropertySnapshot(KnownProperties.Terminal.ConsumerUdsPath, consumerUdsPath) { IsSensitive = true });
        }
 
        return properties;
    }
 
    // Maps the per-replica DCP resourceId (e.g. "myapp-abc123") back to its stable 0-based replica
    // index by consulting DcpInstancesAnnotation, which DcpNameGenerator populates at
    // instance-allocation time. Falls back to 0 for non-DCP resources or when the annotation isn't
    // present yet (e.g. initial pre-DCP snapshots).
    private static int ResolveReplicaIndex(IResource resource, string resourceId)
    {
        var instances = resource.Annotations.OfType<DcpInstancesAnnotation>().FirstOrDefault();
        if (instances is null)
        {
            return 0;
        }
 
        foreach (var instance in instances.Instances)
        {
            if (string.Equals(instance.Name, resourceId, StringComparison.Ordinal))
            {
                return instance.Index;
            }
        }
 
        return 0;
    }
}