// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;
using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Diagnostics;
using Aspire.Shared;
using Aspire.Shared.ConsoleLogs;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Aspire.Hosting.Backchannel;
/// <summary>
/// RPC target for the auxiliary backchannel.
/// </summary>
internal sealed class AuxiliaryBackchannelRpcTarget(
ILogger<AuxiliaryBackchannelRpcTarget> logger,
IConfiguration configuration,
ProfilingTelemetry profilingTelemetry,
IServiceProvider serviceProvider)
{
private static readonly TimeSpan s_mcpDiscoveryTimeout = TimeSpan.FromSeconds(5);
#region V2 API Methods
/// <summary>
/// Gets the capabilities supported by this auxiliary backchannel.
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The capabilities response containing supported versions.</returns>
#pragma warning disable CA1822 // Mark members as static - RPC methods cannot be static
public Task<GetCapabilitiesResponse> GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default)
#pragma warning restore CA1822
{
_ = cancellationToken;
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetCapabilitiesAsync), streaming: false, request?.TraceContext);
return Task.FromResult(new GetCapabilitiesResponse
{
Capabilities =
[
AuxiliaryBackchannelCapabilities.V1,
AuxiliaryBackchannelCapabilities.V2,
AuxiliaryBackchannelCapabilities.V3,
AuxiliaryBackchannelCapabilities.Terminals_V1,
AuxiliaryBackchannelCapabilities.ResourceSnapshotVersions_V1
]
});
}
/// <summary>
/// Gets AppHost information (v2 API with request object).
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The AppHost information response.</returns>
public async Task<GetAppHostInfoResponse> GetAppHostInfoAsync(GetAppHostInfoRequest? request = null, CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetAppHostInfoAsync), streaming: false, request?.TraceContext);
var legacyInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false);
return new GetAppHostInfoResponse
{
Pid = legacyInfo.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture),
AspireHostVersion = AssemblyVersionHelper.GetDisplayVersion(typeof(AuxiliaryBackchannelRpcTarget).Assembly) ?? "unknown",
AppHostPath = legacyInfo.AppHostPath,
CliProcessId = legacyInfo.CliProcessId,
StartedAt = legacyInfo.StartedAt,
CliLogFilePath = legacyInfo.CliLogFilePath
};
}
/// <summary>
/// Gets Dashboard information (v2 API with request object).
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The Dashboard information response.</returns>
public async Task<GetDashboardInfoResponse> GetDashboardInfoAsync(GetDashboardInfoRequest? request = null, CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetDashboardInfoAsync), streaming: false, request?.TraceContext);
var info = await DashboardUrlsHelper.GetDashboardConnectionInfoAsync(serviceProvider, logger, cancellationToken).ConfigureAwait(false);
var urls = new List<string>(2);
if (!string.IsNullOrEmpty(info.BaseUrlWithLoginToken))
{
urls.Add(info.BaseUrlWithLoginToken);
}
if (!string.IsNullOrEmpty(info.CodespacesUrlWithLoginToken))
{
urls.Add(info.CodespacesUrlWithLoginToken);
}
return new GetDashboardInfoResponse
{
ApiBaseUrl = info.ApiBaseUrl,
ApiToken = info.ApiToken,
DashboardUrls = urls.ToArray(),
IsHealthy = info.IsHealthy
};
}
/// <summary>
/// Gets resource snapshots (v2 API with request object).
/// </summary>
/// <param name="request">The request with optional filtering.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The resources response containing snapshots.</returns>
public async Task<GetResourcesResponse> GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetResourcesAsync), streaming: false, request?.TraceContext);
var snapshots = await GetResourceSnapshotsAsync(SupportsJsonResourceProperties(request?.ClientCapabilities), cancellationToken).ConfigureAwait(false);
// Apply filter if specified
if (!string.IsNullOrEmpty(request?.Filter))
{
var filter = request.Filter;
snapshots = snapshots.Where(s => s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
}
return new GetResourcesResponse
{
Resources = snapshots.ToArray()
};
}
/// <summary>
/// Watches for resource changes (v2 API with request object).
/// </summary>
/// <param name="request">The request with optional filtering.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of resource snapshots as they change.</returns>
public async IAsyncEnumerable<ResourceSnapshot> WatchResourcesAsync(WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(WatchResourcesAsync), streaming: true, request?.TraceContext);
var filter = request?.Filter;
var yieldedCount = 0;
try
{
await foreach (var snapshot in WatchResourceSnapshotsAsync(SupportsJsonResourceProperties(request?.ClientCapabilities), cancellationToken).ConfigureAwait(false))
{
// Apply filter if specified
if (!string.IsNullOrEmpty(filter) && !snapshot.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (yieldedCount == 0)
{
activity.AddJsonRpcStreamFirstItemEvent();
}
yieldedCount++;
yield return snapshot;
}
activity.AddJsonRpcStreamCompletedEvent();
}
finally
{
activity.SetJsonRpcStreamItemCount(yieldedCount);
}
}
/// <summary>
/// Gets console logs (v2 API with request object).
/// </summary>
/// <param name="request">The request specifying resource and options.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of log lines.</returns>
public IAsyncEnumerable<ResourceLogLine> GetConsoleLogsAsync(GetConsoleLogsRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return GetResourceLogsAsync(
"GetConsoleLogsAsync",
request.TraceContext,
request.ResourceName,
request.Follow,
request.IncludeHidden,
request.Search,
request.Tail,
cancellationToken);
}
/// <summary>
/// Gets console logs in batches to reduce per-item JSON-RPC stream overhead.
/// </summary>
/// <param name="request">The request specifying resource and options.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of log batches.</returns>
public IAsyncEnumerable<ResourceLogBatch> GetConsoleLogBatchesAsync(GetConsoleLogsRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return GetResourceLogBatchesAsync(
"GetConsoleLogBatchesAsync",
request.TraceContext,
request.ResourceName,
request.Follow,
request.IncludeHidden,
request.Search,
request.Tail,
cancellationToken);
}
/// <summary>
/// Calls an MCP tool on a resource (v2 API with request object).
/// </summary>
/// <param name="request">The request specifying resource, tool, and arguments.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The tool call response.</returns>
public async Task<CallMcpToolResponse> CallMcpToolAsync(CallMcpToolRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(CallMcpToolAsync), streaming: false, request.TraceContext);
// Convert JsonElement arguments to Dictionary<string, object?> with proper value conversion
var arguments = new Dictionary<string, object?>();
if (request.Arguments is JsonElement argsElement && argsElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in argsElement.EnumerateObject())
{
arguments[prop.Name] = ConvertJsonElementToObject(prop.Value);
}
}
var result = await CallResourceMcpToolAsync(request.ResourceName, request.ToolName, arguments, cancellationToken).ConfigureAwait(false);
return new CallMcpToolResponse
{
IsError = result.IsError ?? false,
Content = result.Content.Select(c => new McpToolContentItem
{
Type = c.Type,
Text = (c as ModelContextProtocol.Protocol.TextContentBlock)?.Text
}).ToArray()
};
}
/// <summary>
/// Stops the AppHost (v2 API with request object).
/// </summary>
/// <param name="request">The stop request.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The stop response.</returns>
public async Task<StopAppHostResponse> StopAsync(StopAppHostRequest? request = null, CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(StopAsync), streaming: false, request?.TraceContext);
await StopAppHostAsync(cancellationToken).ConfigureAwait(false);
return new StopAppHostResponse();
}
/// <summary>
/// Executes a command on a resource.
/// </summary>
/// <param name="request">The request containing resource name and command name.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The response indicating success or failure.</returns>
public async Task<ExecuteResourceCommandResponse> ExecuteResourceCommandAsync(ExecuteResourceCommandRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(ExecuteResourceCommandAsync), streaming: false, request.TraceContext);
var resourceCommandService = serviceProvider.GetRequiredService<ResourceCommandService>();
var (arguments, argumentErrorMessage) = CreateCommandArguments(resourceCommandService, request);
if (argumentErrorMessage is not null)
{
return new ExecuteResourceCommandResponse
{
Success = false,
Message = argumentErrorMessage,
#pragma warning disable CS0618 // Type or member is obsolete
ErrorMessage = argumentErrorMessage,
#pragma warning restore CS0618 // Type or member is obsolete
};
}
ExecuteCommandResult result;
InteractionInputCollection? loadedArguments = null;
if (request.ValidateOnly)
{
(result, loadedArguments) = await ValidateResourceCommandAsync(resourceCommandService, request.ResourceName, request.CommandName, arguments, cancellationToken).ConfigureAwait(false);
}
else
{
result = await resourceCommandService.ExecuteCommandAsync(
request.ResourceName,
request.CommandName,
new ResourceCommandExecutionOptions
{
Arguments = arguments,
ArgumentsProvided = request.Arguments is not null,
NonInteractive = request.NonInteractive
},
cancellationToken).ConfigureAwait(false);
}
#pragma warning disable CS0618 // Type or member is obsolete
var resolvedMessage = result.Message ?? result.ErrorMessage;
#pragma warning restore CS0618 // Type or member is obsolete
return new ExecuteResourceCommandResponse
{
Success = result.Success,
Canceled = result.Canceled,
#pragma warning disable CS0618 // Type or member is obsolete
ErrorMessage = resolvedMessage,
#pragma warning restore CS0618 // Type or member is obsolete
Message = resolvedMessage,
ValidationErrors = CreateValidationErrors(result.InvalidArguments),
ArgumentInputs = request.ReturnArgumentInputs && loadedArguments is not null
? loadedArguments.Select(CreateCommandArgument).ToArray()
: null,
Value = result.Data is { } v ? new ExecuteResourceCommandResult
{
Value = v.Value,
Format = v.Format switch
{
ApplicationModel.CommandResultFormat.Json => CommandResultFormat.Json,
ApplicationModel.CommandResultFormat.Markdown => CommandResultFormat.Markdown,
_ => CommandResultFormat.Text
},
DisplayImmediately = v.DisplayImmediately
} : null
};
}
private static (InteractionInputCollection Arguments, string? ErrorMessage) CreateCommandArguments(ResourceCommandService resourceCommandService, ExecuteResourceCommandRequest request)
{
var arguments = request.Arguments;
if (arguments is null)
{
return resourceCommandService.CreateCommandArguments(request.ResourceName, request.CommandName, argumentValues: null);
}
return arguments.GetValueKind() switch
{
JsonValueKind.Object => resourceCommandService.CreateCommandArguments(request.ResourceName, request.CommandName, ConvertObjectArgumentValues(arguments.AsObject())),
JsonValueKind.Array => resourceCommandService.CreateCommandArguments(request.ResourceName, request.CommandName, ConvertOrderedArgumentValues(arguments.AsArray())),
_ => throw new InvalidOperationException("Resource command arguments must be a JSON object or array.")
};
}
private static async Task<(ExecuteCommandResult Result, InteractionInputCollection? Arguments)> ValidateResourceCommandAsync(ResourceCommandService resourceCommandService, string resourceName, string commandName, InteractionInputCollection arguments, CancellationToken cancellationToken)
{
return await resourceCommandService.ValidateCommandArgumentsAsync(resourceName, commandName, arguments, cancellationToken).ConfigureAwait(false);
}
private static ResourceCommandArgumentValidationError[] CreateValidationErrors(InteractionInputCollection? invalidArguments)
{
return invalidArguments is null
? []
: invalidArguments
.SelectMany(argument => argument.ValidationErrors.Select(error => new ResourceCommandArgumentValidationError
{
ArgumentName = argument.Name,
ErrorMessage = error
}))
.ToArray();
}
private static IReadOnlyDictionary<string, string?> ConvertObjectArgumentValues(JsonObject arguments)
{
// ExecuteResourceCommandRequest arguments are encoded as a JSON object in the auxiliary backchannel protocol:
// {
// "resourceName": "web-browser-logs",
// "commandName": "click",
// "arguments": { "selector": "#submit" }
// }
var values = new Dictionary<string, string?>(StringComparers.InteractionInputName);
foreach (var property in arguments)
{
values[property.Key] = ConvertArgumentValue(property.Key, property.Value);
}
return values;
}
private static string?[] ConvertOrderedArgumentValues(JsonArray arguments)
{
// ExecuteResourceCommandRequest arguments can be encoded as a JSON array in the auxiliary backchannel protocol:
// {
// "resourceName": "web-browser-logs",
// "commandName": "click",
// "arguments": [ "#submit" ]
// }
return arguments
.Select((value, index) => ConvertArgumentValue($"[{index}]", value))
.ToArray();
}
private static string? ConvertArgumentValue(string name, JsonNode? value)
{
if (value is null)
{
return null;
}
return value.GetValueKind() switch
{
JsonValueKind.String => value.GetValue<string>(),
JsonValueKind.Number => value.ToJsonString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => throw new InvalidOperationException($"Resource command argument '{name}' must be a string, number, boolean, or null.")
};
}
/// <summary>
/// Returns the discovery info needed to attach to a resource's terminal session(s). For a
/// resource configured with <c>WithTerminal()</c>, this enumerates the per-replica
/// consumer-side UDS endpoints by asking each per-replica terminal host process over its
/// control UDS in parallel. Returns <see cref="GetTerminalInfoResponse.IsAvailable"/> = false
/// when the resource has no <see cref="TerminalAnnotation"/>; per-host control RPC failures
/// (e.g. host not yet started) degrade to <see cref="TerminalReplicaInfo.IsAlive"/> = false
/// for that replica without failing the whole call.
/// </summary>
public async Task<GetTerminalInfoResponse> GetTerminalInfoAsync(GetTerminalInfoRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var appModel = serviceProvider.GetRequiredService<DistributedApplicationModel>();
var resource = appModel.Resources.FirstOrDefault(r => string.Equals(r.Name, request.ResourceName, StringComparisons.ResourceName));
if (resource is null)
{
logger.LogDebug("GetTerminalInfo: resource '{ResourceName}' not found.", request.ResourceName);
return new GetTerminalInfoResponse { IsAvailable = false };
}
var terminalAnnotation = resource.Annotations.OfType<TerminalAnnotation>().FirstOrDefault();
if (terminalAnnotation is null)
{
logger.LogDebug("GetTerminalInfo: resource '{ResourceName}' has no TerminalAnnotation.", request.ResourceName);
return new GetTerminalInfoResponse { IsAvailable = false };
}
var (replicas, _) = await CollectReplicaInfosAsync(
request.ResourceName,
terminalAnnotation,
cancellationToken).ConfigureAwait(false);
return new GetTerminalInfoResponse
{
IsAvailable = true,
Replicas = replicas,
Columns = terminalAnnotation.Options.Columns,
Rows = terminalAnnotation.Options.Rows,
};
}
/// <summary>
/// Fans out across each per-replica terminal host's control UDS in parallel and returns
/// the assembled per-replica info array plus an aggregate flag indicating whether at
/// least one host responded. Per-host failures (host not yet started, control RPC timeout)
/// materialize as a <see cref="TerminalReplicaInfo"/> with the AppHost-known consumer UDS
/// path and <see cref="TerminalReplicaInfo.IsAlive"/> = false, so callers can always render
/// a row per replica even when a host hasn't started.
/// </summary>
private async Task<(TerminalReplicaInfo[] Replicas, bool AnyHostReachable)> CollectReplicaInfosAsync(
string resourceName,
TerminalAnnotation terminalAnnotation,
CancellationToken cancellationToken)
{
var hosts = terminalAnnotation.TerminalHosts;
var tasks = new Task<(TerminalReplicaInfo Info, bool HostResponded)>[hosts.Count];
for (var i = 0; i < hosts.Count; i++)
{
tasks[i] = QueryReplicaAsync(resourceName, hosts[i], cancellationToken);
}
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
var replicas = new TerminalReplicaInfo[results.Length];
var anyResponded = false;
for (var i = 0; i < results.Length; i++)
{
replicas[i] = results[i].Info;
anyResponded |= results[i].HostResponded;
}
return (replicas, anyResponded);
}
/// <summary>
/// Queries a single per-replica host's control UDS and translates the response (or the
/// failure) into a <see cref="TerminalReplicaInfo"/>. The AppHost is the source of truth
/// for the consumer UDS path and replica index — those come from <see cref="TerminalHostResource.Layout"/>
/// rather than from the host's echoed reply. The returned <c>HostResponded</c> flag is true
/// only when the control RPC actually succeeded.
/// </summary>
private async Task<(TerminalReplicaInfo Info, bool HostResponded)> QueryReplicaAsync(
string resourceName,
TerminalHostResource host,
CancellationToken cancellationToken)
{
var layout = host.Layout;
var replicaIndex = layout.ParentReplicaIndex;
Aspire.Shared.TerminalHost.TerminalHostSessionInfo? session = null;
try
{
session = await TerminalHostControlClient.GetSessionAsync(
layout.ControlUdsPath,
totalTimeout: TimeSpan.FromSeconds(3),
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogDebug(
"Terminal host control RPC timed out for resource '{ResourceName}' replica {ReplicaIndex} (control path '{ControlPath}').",
resourceName, replicaIndex, layout.ControlUdsPath);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogDebug(
ex,
"Terminal host control RPC failed for resource '{ResourceName}' replica {ReplicaIndex} (control path '{ControlPath}').",
resourceName, replicaIndex, layout.ControlUdsPath);
}
var info = new TerminalReplicaInfo
{
ReplicaIndex = replicaIndex,
Label = $"replica {replicaIndex}",
ConsumerUdsPath = layout.ConsumerUdsPath,
IsAlive = session?.IsAlive ?? false,
ExitCode = session?.ExitCode,
ProducerConnected = session?.ProducerConnected ?? false,
RestartCount = session?.RestartCount ?? 0,
CurrentColumns = session?.CurrentColumns,
CurrentRows = session?.CurrentRows,
AttachedPeerCount = session?.AttachedPeerCount,
Peers = ConvertPeers(session?.Peers),
};
return (info, session is not null);
}
/// <summary>
/// Translates a host-side <see cref="Aspire.Shared.TerminalHost.TerminalHostPeerInfo"/> array
/// to the wire-facing <see cref="TerminalPeerInfo"/> array that ships over JsonRpc. Returns
/// null when the host didn't supply any peer info (older host) so newer clients can detect
/// "no info available" vs. "info available, currently empty".
/// </summary>
private static TerminalPeerInfo[]? ConvertPeers(Aspire.Shared.TerminalHost.TerminalHostPeerInfo[]? hostPeers)
{
if (hostPeers is null)
{
return null;
}
if (hostPeers.Length == 0)
{
return Array.Empty<TerminalPeerInfo>();
}
var result = new TerminalPeerInfo[hostPeers.Length];
for (var i = 0; i < hostPeers.Length; i++)
{
result[i] = new TerminalPeerInfo
{
PeerId = hostPeers[i].PeerId,
DisplayName = hostPeers[i].DisplayName,
};
}
return result;
}
/// <summary>
/// Lists every <c>WithTerminal</c>-enabled resource in the AppHost, with current grid size and
/// attached-peer details. Used by <c>aspire terminal ps</c>. Each per-resource snapshot is
/// independent: a resource whose terminal host hasn't started yet (or whose control RPC times
/// out) is reported with <see cref="TerminalSummary.IsHostReachable"/> = false rather than
/// failing the whole listing.
/// </summary>
public async Task<ListTerminalsResponse> ListTerminalsAsync(ListTerminalsRequest? request = null, CancellationToken cancellationToken = default)
{
_ = request;
var appModel = serviceProvider.GetRequiredService<DistributedApplicationModel>();
var terminals = new List<TerminalSummary>();
foreach (var resource in appModel.Resources)
{
var terminalAnnotation = resource.Annotations.OfType<TerminalAnnotation>().FirstOrDefault();
if (terminalAnnotation is null)
{
continue;
}
var (replicas, anyHostReachable) = await CollectReplicaInfosAsync(
resource.Name,
terminalAnnotation,
cancellationToken).ConfigureAwait(false);
terminals.Add(new TerminalSummary
{
ResourceName = resource.Name,
DisplayName = resource.Name,
ConfiguredColumns = terminalAnnotation.Options.Columns,
ConfiguredRows = terminalAnnotation.Options.Rows,
IsHostReachable = anyHostReachable,
// Always emit the per-replica array, even when no host is reachable.
// CollectReplicaInfosAsync returns one entry per replica with AppHost-known
// ReplicaIndex / ConsumerUdsPath populated and IsAlive=false on degraded
// entries. Dropping the array on aggregate failure would make `aspire
// terminal ps` blanker in the failure case than in the success case —
// exactly when users need the diagnostic shape — and would diverge from
// GetTerminalInfoAsync, which keeps the degraded per-replica shape.
Replicas = replicas,
});
}
return new ListTerminalsResponse
{
Terminals = [.. terminals],
};
}
/// <summary>
/// Waits for a resource to reach a target status.
/// </summary>
public async Task<WaitForResourceResponse> WaitForResourceAsync(WaitForResourceRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(WaitForResourceAsync), streaming: false, request.TraceContext);
var notificationService = serviceProvider.GetRequiredService<ResourceNotificationService>();
var targetResolution = ResolveWaitTarget(notificationService, request.ResourceName);
var targetResource = targetResolution.Target;
if (targetResource is null)
{
return new WaitForResourceResponse
{
Success = false,
ResourceNotFound = targetResolution.ResourceNotFound,
ErrorMessage = targetResolution.ErrorMessage
};
}
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(request.TimeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
return request.Status switch
{
"healthy" => await WaitForHealthyAsync(notificationService, targetResource, linkedCts.Token).ConfigureAwait(false),
"up" => await WaitForRunningAsync(notificationService, targetResource, linkedCts.Token).ConfigureAwait(false),
"down" => await WaitForTerminalAsync(notificationService, targetResource, linkedCts.Token).ConfigureAwait(false),
_ => new WaitForResourceResponse { Success = false, ErrorMessage = $"Unknown status: {request.Status}" }
};
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
return new WaitForResourceResponse { Success = false, TimedOut = true, ErrorMessage = $"Timed out waiting for resource '{request.ResourceName}'." };
}
catch (DistributedApplicationException ex)
{
return new WaitForResourceResponse { Success = false, ErrorMessage = ex.Message };
}
}
private static async Task<WaitForResourceResponse> WaitForHealthyAsync(ResourceNotificationService notificationService, WaitResourceTarget target, CancellationToken cancellationToken)
{
var resourceEvent = await WaitForResourceEventAsync(
notificationService,
target,
re => ResourceNotificationService.ShouldYieldHealthyWait(WaitBehavior.StopOnResourceUnavailable, re.Snapshot),
$"Resource '{target.DisplayName}' failed to become healthy before the operation was cancelled.",
cancellationToken).ConfigureAwait(false);
if (resourceEvent.Snapshot.HealthStatus != HealthStatus.Healthy)
{
throw new DistributedApplicationException($"Stopped waiting for resource '{target.DisplayName}' to become healthy because it failed to start.");
}
resourceEvent = await WaitForResourceEventAsync(
notificationService,
new WaitResourceTarget(target.DisplayName, resourceEvent.ResourceId, null),
re => re.Snapshot.ResourceReadyEvent is not null,
$"Resource '{target.DisplayName}' failed to execute the resource ready event before the operation was cancelled.",
cancellationToken).ConfigureAwait(false);
await resourceEvent.Snapshot.ResourceReadyEvent!.EventTask.WaitAsync(cancellationToken).ConfigureAwait(false);
return new WaitForResourceResponse
{
Success = true,
State = resourceEvent.Snapshot.State?.Text,
HealthStatus = resourceEvent.Snapshot.HealthStatus?.ToString()
};
}
private static async Task<WaitForResourceResponse> WaitForRunningAsync(ResourceNotificationService notificationService, WaitResourceTarget target, CancellationToken cancellationToken)
{
var resourceEvent = await WaitForResourceEventAsync(
notificationService,
target,
re => re.Snapshot.State?.Text == KnownResourceStates.Running ||
KnownResourceStates.TerminalStates.Contains(re.Snapshot.State?.Text, StringComparers.ResourceState) ||
string.Equals(re.Snapshot.State?.Style, KnownResourceStateStyles.Error, StringComparisons.ResourceState) ||
re.Snapshot.ExitCode is not null,
$"Resource '{target.DisplayName}' failed to reach the target state before the operation was cancelled.",
cancellationToken).ConfigureAwait(false);
var state = resourceEvent.Snapshot.State?.Text;
var isRunning = state == KnownResourceStates.Running;
return new WaitForResourceResponse
{
Success = isRunning,
State = state,
HealthStatus = resourceEvent.Snapshot.HealthStatus?.ToString(),
ErrorMessage = isRunning ? null : $"Resource '{target.DisplayName}' failed to reach 'Running' state. Current state: {state ?? "Unknown"}."
};
}
private static async Task<WaitForResourceResponse> WaitForTerminalAsync(ResourceNotificationService notificationService, WaitResourceTarget target, CancellationToken cancellationToken)
{
var resourceEvent = await WaitForResourceEventAsync(
notificationService,
target,
re => KnownResourceStates.TerminalStates.Contains(re.Snapshot.State?.Text) || re.Snapshot.ExitCode is not null,
$"Resource '{target.DisplayName}' failed to reach the target state before the operation was cancelled.",
cancellationToken).ConfigureAwait(false);
return new WaitForResourceResponse
{
Success = true,
State = resourceEvent.Snapshot.State?.Text,
HealthStatus = resourceEvent.Snapshot.HealthStatus?.ToString()
};
}
private static async Task<ResourceEvent> WaitForResourceEventAsync(
ResourceNotificationService notificationService,
WaitResourceTarget target,
Func<ResourceEvent, bool> predicate,
string cancellationMessage,
CancellationToken cancellationToken)
{
try
{
await foreach (var resourceEvent in notificationService.WatchAsync(cancellationToken).ConfigureAwait(false))
{
if (target.Matches(resourceEvent) && predicate(resourceEvent))
{
return resourceEvent;
}
}
}
catch (OperationCanceledException ex)
{
throw new OperationCanceledException(cancellationMessage, ex, ex.CancellationToken);
}
throw new OperationCanceledException(cancellationMessage);
}
private WaitTargetResolutionResult ResolveWaitTarget(ResourceNotificationService notificationService, string requestedResourceName)
{
var appModel = serviceProvider.GetRequiredService<DistributedApplicationModel>();
if (notificationService.TryGetCurrentState(requestedResourceName, out var resourceEvent))
{
return WaitTargetResolutionResult.Success(new WaitResourceTarget(
ResolveDisplayName(appModel, requestedResourceName, resourceEvent.ResourceId),
resourceEvent.ResourceId,
null));
}
// During startup the resource may not have published its first snapshot yet, so fall back to
// the app model to resolve the requested logical name or resolved resource id.
var matchingResource = appModel.Resources.SingleOrDefault(resource => string.Equals(resource.Name, requestedResourceName, StringComparisons.ResourceName));
if (matchingResource is not null)
{
var resolvedResourceNames = matchingResource.GetResolvedResourceNames();
return resolvedResourceNames.Length switch
{
1 => WaitTargetResolutionResult.Success(new WaitResourceTarget(requestedResourceName, resolvedResourceNames[0], null)),
> 1 => WaitTargetResolutionResult.Ambiguous(requestedResourceName),
_ => WaitTargetResolutionResult.NotFound(requestedResourceName)
};
}
var resolvedMatches = appModel.Resources
.Select(resource => new { Resource = resource, ResolvedResourceNames = resource.GetResolvedResourceNames() })
.Where(match => match.ResolvedResourceNames.Any(resourceName => string.Equals(resourceName, requestedResourceName, StringComparisons.ResourceName)))
.Take(2)
.ToArray();
return resolvedMatches.Length switch
{
1 => WaitTargetResolutionResult.Success(new WaitResourceTarget(
resolvedMatches[0].ResolvedResourceNames.Length == 1 ? resolvedMatches[0].Resource.Name : requestedResourceName,
requestedResourceName,
null)),
> 1 => WaitTargetResolutionResult.Ambiguous(requestedResourceName),
_ => WaitTargetResolutionResult.NotFound(requestedResourceName)
};
}
private static string ResolveDisplayName(DistributedApplicationModel appModel, string requestedResourceName, string resolvedResourceName)
{
var matchingResource = appModel.Resources
.Select(resource => new { Resource = resource, ResolvedResourceNames = resource.GetResolvedResourceNames() })
.SingleOrDefault(match => match.ResolvedResourceNames.Any(resourceName => string.Equals(resourceName, resolvedResourceName, StringComparisons.ResourceName)));
return matchingResource is { ResolvedResourceNames.Length: 1 }
? matchingResource.Resource.Name
: requestedResourceName;
}
private sealed record WaitResourceTarget(string DisplayName, string? ResourceId, string? ResourceName)
{
public bool Matches(ResourceEvent resourceEvent)
{
return (ResourceId is not null && string.Equals(resourceEvent.ResourceId, ResourceId, StringComparisons.ResourceName))
|| (ResourceName is not null && string.Equals(resourceEvent.Resource.Name, ResourceName, StringComparisons.ResourceName));
}
}
private sealed record WaitTargetResolutionResult(WaitResourceTarget? Target, bool ResourceNotFound, string ErrorMessage)
{
public static WaitTargetResolutionResult Success(WaitResourceTarget target) => new(target, ResourceNotFound: false, string.Empty);
public static WaitTargetResolutionResult NotFound(string requestedResourceName) => new(
null,
ResourceNotFound: true,
$"Resource '{requestedResourceName}' was not found.");
public static WaitTargetResolutionResult Ambiguous(string requestedResourceName) => new(
null,
ResourceNotFound: false,
$"Resource '{requestedResourceName}' is ambiguous because it has multiple replicas. Specify the exact instance name.");
}
#endregion
#region V1 API Methods (Legacy - Keep for backward compatibility)
/// <summary>
/// Gets information about the AppHost for the MCP server.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The AppHost information including the fully qualified path and process ID.</returns>
/// <exception cref="InvalidOperationException">Thrown when AppHost information is not available.</exception>
public Task<AppHostInformation> GetAppHostInformationAsync(CancellationToken cancellationToken = default)
{
// The cancellationToken parameter is not currently used, but is retained for API consistency and potential future support for cancellation.
_ = cancellationToken;
// First try to get the file path (with extension), otherwise fall back to the path (without extension)
var appHostPath = configuration["AppHost:FilePath"] ?? configuration["AppHost:Path"];
if (string.IsNullOrEmpty(appHostPath))
{
logger.LogError("AppHost path not found in configuration.");
throw new InvalidOperationException("AppHost path not found in configuration.");
}
// Get the CLI process ID if the AppHost was launched via the CLI
int? cliProcessId = null;
var cliPidString = configuration[KnownConfigNames.CliProcessId];
if (!string.IsNullOrEmpty(cliPidString) && int.TryParse(cliPidString, out var parsedCliPid))
{
cliProcessId = parsedCliPid;
}
DateTimeOffset? cliStartedAt = null;
var cliStartedAtString = configuration[KnownConfigNames.CliProcessStarted];
if (!string.IsNullOrEmpty(cliStartedAtString) && long.TryParse(cliStartedAtString, out var parsedCliStartedAt))
{
cliStartedAt = DateTimeOffset.FromUnixTimeSeconds(parsedCliStartedAt);
}
// ASPIRE_CLI_STARTED_STABLE is the stable launcher-CLI identity time that current CLIs stamp
// alongside the legacy value, in Unix milliseconds. It survives wall-clock steps, so surfacing it
// lets the CLI-side orphan collector verify the launching CLI with an exact PID-reuse check
// instead of the drift-prone legacy value. Older CLIs do not stamp it, so it stays null for them.
DateTimeOffset? cliStableStartedAt = null;
var cliStableStartedAtString = configuration[KnownConfigNames.CliProcessStartedStable];
if (!string.IsNullOrEmpty(cliStableStartedAtString) && long.TryParse(cliStableStartedAtString, out var parsedCliStableStartedAt))
{
cliStableStartedAt = DateTimeOffset.FromUnixTimeMilliseconds(parsedCliStableStartedAt);
}
using var currentProcess = Process.GetCurrentProcess();
return Task.FromResult(new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = Environment.ProcessId,
CliProcessId = cliProcessId,
// StartedAt is the legacy backchannel field, and released AppHosts populate it from Process.StartTime.
// Keep doing for mixed-version CLI compatibility.
// StableStartedAt carries millisecond-precision, stable process identity time for exact reuse checks.
StartedAt = new DateTimeOffset(currentProcess.StartTime),
StableStartedAt = ProcessStartTimeHelper.TryGetProcessStartTime(Environment.ProcessId),
CliStartedAt = cliStartedAt,
CliStableStartedAt = cliStableStartedAt,
CliLogFilePath = configuration[KnownConfigNames.CliLogFilePath]
});
}
/// <summary>
/// Gets the dashboard URLs for the running AppHost.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The dashboard URL state including health and resolved dashboard URLs.</returns>
public async Task<DashboardUrlsState> GetDashboardUrlsAsync(CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetDashboardUrlsAsync), streaming: false);
logger.LogDebug("GetDashboardUrlsAsync called on auxiliary backchannel");
try
{
var urls = await DashboardUrlsHelper.GetDashboardUrlsAsync(serviceProvider, logger, cancellationToken).ConfigureAwait(false);
activity.SetDashboardHealthy(urls.DashboardHealthy);
return urls;
}
catch (Exception ex)
{
activity.SetError(ex);
throw;
}
}
/// <summary>
/// Waits until the AppHost has reached its startup readiness point.
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The startup readiness state.</returns>
public async Task<WaitForAppHostReadyResponse> WaitForAppHostReadyAsync(WaitForAppHostReadyRequest? request = null, CancellationToken cancellationToken = default)
{
using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(WaitForAppHostReadyAsync), streaming: false, request?.TraceContext);
var startupState = serviceProvider.GetRequiredService<AppHostStartupState>();
await startupState.WaitForReadyAsync(cancellationToken).ConfigureAwait(false);
return new WaitForAppHostReadyResponse { IsReady = true };
}
/// <summary>
/// Preserved for backwards compatibility with older CLI versions that call this RPC method.
/// Always returns <see langword="null"/> because the dashboard MCP server has been removed.
/// </summary>
#pragma warning disable CA1822 // Mark members as static - RPC methods cannot be static
public Task<DashboardMcpConnectionInfo?> GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult<DashboardMcpConnectionInfo?>(null);
}
#pragma warning restore CA1822
/// <summary>
/// Gets the current resource snapshots for all resources.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A list of resource snapshots.</returns>
public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default)
{
return await GetResourceSnapshotsAsync(resourcePropertiesAsJson: false, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(bool resourcePropertiesAsJson, CancellationToken cancellationToken)
{
var appModel = serviceProvider.GetRequiredService<DistributedApplicationModel>();
var notificationService = serviceProvider.GetRequiredService<ResourceNotificationService>();
var results = new List<ResourceSnapshot>();
// Get current state for each resource directly using TryGetCurrentState
foreach (var resource in appModel.Resources)
{
foreach (var instanceName in resource.GetResolvedResourceNames())
{
await AddResult(instanceName).ConfigureAwait(false);
}
}
return results;
async Task AddResult(string resourceName)
{
if (notificationService.TryGetCurrentState(resourceName, out var resourceEvent))
{
// The secret redaction set is resolved per snapshot inside CreateResourceSnapshotFromEventAsync,
// not once for the whole batch. Building each snapshot can await MCP tool discovery for up to
// s_mcpDiscoveryTimeout, and a parameter can resolve during that window, so a set computed once up
// front could miss a secret that a later resource's snapshot already carries and leak it in
// plaintext. Resolving per snapshot keeps redaction bound to each snapshot.
var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, resourcePropertiesAsJson, cancellationToken).ConfigureAwait(false);
if (snapshot is not null)
{
results.Add(snapshot);
}
}
}
}
/// <summary>
/// Watches for resource snapshot changes and streams them to the client.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of resource snapshots as they change.</returns>
public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var snapshot in WatchResourceSnapshotsAsync(resourcePropertiesAsJson: false, cancellationToken).ConfigureAwait(false))
{
yield return snapshot;
}
}
private async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync(
bool resourcePropertiesAsJson,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var notificationService = serviceProvider.GetRequiredService<ResourceNotificationService>();
var resourceEvents = notificationService.WatchAsync(cancellationToken);
await foreach (var resourceEvent in resourceEvents.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// The secret redaction set is resolved per event inside CreateResourceSnapshotFromEventAsync, so a
// secret resolved (or replaced, or newly referenced after a resource restart) while the watch is open
// is reflected on later events rather than fixed to when the watch started.
var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, resourcePropertiesAsJson, cancellationToken).ConfigureAwait(false);
if (snapshot is not null)
{
yield return snapshot;
}
}
}
private async Task<ResourceSnapshot?> CreateResourceSnapshotFromEventAsync(
ResourceEvent resourceEvent,
bool resourcePropertiesAsJson,
CancellationToken cancellationToken)
{
var resource = resourceEvent.Resource;
var snapshot = resourceEvent.Snapshot;
// Get MCP server info if available
ResourceSnapshotMcpServer? mcpServer = null;
if (resource is IResourceWithEndpoints resourceWithEndpoints &&
resourceWithEndpoints.TryGetLastAnnotation<McpServerEndpointAnnotation>(out var mcpAnnotation))
{
var endpointUri = await mcpAnnotation.EndpointUrlResolver(resourceWithEndpoints, cancellationToken).ConfigureAwait(false);
if (endpointUri is not null)
{
var tools = await TryListToolsAsync(endpointUri, cancellationToken).ConfigureAwait(false);
if (tools is not null)
{
mcpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = endpointUri.ToString(),
Tools = tools
};
}
}
}
// Build URLs
var urls = snapshot.Urls
.Where(u => !u.IsInactive && !string.IsNullOrEmpty(u.Url))
.Select(u => new ResourceSnapshotUrl
{
Name = u.Name ?? "default",
Url = u.Url,
IsInternal = u.IsInternal,
DisplayProperties = new ResourceSnapshotUrlDisplayProperties
{
DisplayName = string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? null : u.DisplayProperties.DisplayName,
SortOrder = u.DisplayProperties.SortOrder
}
})
.ToArray();
// Build relationships
var relationships = snapshot.Relationships
.Select(r => new ResourceSnapshotRelationship
{
ResourceName = r.ResourceName,
Type = r.Type
})
.ToArray();
// Build health reports
var healthReports = snapshot.HealthReports
.Select(h => new ResourceSnapshotHealthReport
{
Name = h.Name,
Status = h.Status?.ToString(),
Description = h.Description,
ExceptionText = h.ExceptionText
})
.ToArray();
// Build volumes
var volumes = snapshot.Volumes
.Select(v => new ResourceSnapshotVolume
{
Source = v.Source,
Target = v.Target,
MountType = v.MountType,
IsReadOnly = v.IsReadOnly
})
.ToArray();
// Resolve the secret redaction set for THIS snapshot, after the MCP discovery await above. The snapshot's
// environment values were captured before that await, so any secret they carry was already resolved by the
// time we get here. Resolving now (rather than once for a whole describe batch) keeps redaction bound to
// the snapshot even though building each snapshot can block on MCP discovery for up to s_mcpDiscoveryTimeout,
// during which another parameter can resolve. The redaction history is add-only and AppHost-scoped (see
// GetResolvedSecretParameterValuesAsync), so resolving per snapshot only ever grows the set and also keeps
// redacting a secret whose value has since been replaced or whose owning resource has restarted.
var secretParameterValues = await GetResolvedSecretParameterValuesAsync(cancellationToken).ConfigureAwait(false);
// Build environment variables. Values that match a secret parameter's value are
// redacted so secrets don't leak through clients (e.g. aspire describe --format json).
var environmentVariables = snapshot.EnvironmentVariables
.Select(e => new ResourceSnapshotEnvironmentVariable
{
Name = e.Name,
Value = RedactIfSecretValue(e.Value, secretParameterValues),
IsFromSpec = e.IsFromSpec
})
.ToArray();
// Build properties dictionary from ResourcePropertySnapshot
// Redact sensitive property values to avoid leaking secrets
//
// Stamp the per-replica terminal properties (terminal.enabled, terminal.replicaIndex,
// terminal.replicaCount) the same way the dashboard gRPC path does so that `aspire describe`
// and the VS Code extension can detect terminal availability and target the right replica.
// The sensitive terminal.consumerUdsPath is redacted to null below by the IsSensitive check,
// which is intentional: the CLI resolves the real socket path via GetTerminalInfoAsync.
var stampedProperties = TerminalResourceSnapshotProperties.AddTerminalProperties(resource, resourceEvent.ResourceId, snapshot.Properties);
var properties = new Dictionary<string, JsonNode?>();
string[]? waitingFor = null;
foreach (var prop in stampedProperties)
{
// Redact sensitive property values
if (prop.IsSensitive)
{
properties[prop.Name] = null;
continue;
}
properties[prop.Name] = resourcePropertiesAsJson
? ConvertPropertyValueToJsonNode(prop.Value)
: ConvertPropertyValueToLegacyJsonNode(prop.Value);
if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName))
{
waitingFor = GetStringArrayPropertyValue(prop.Value);
}
}
// Build commands
var commands = snapshot.Commands
.Select(c => new ResourceSnapshotCommand
{
Name = c.Name,
DisplayName = c.DisplayName,
Description = c.DisplayDescription,
ArgumentInputs = c.Arguments.Select(CreateCommandArgument).ToArray(),
Visibility = c.Visibility.ToString(),
State = c.State.ToString()
})
.ToArray();
return new ResourceSnapshot
{
Name = resourceEvent.ResourceId,
Version = snapshot.Version,
DisplayName = resource.Name,
ResourceType = snapshot.ResourceType,
State = snapshot.State?.Text,
WaitingFor = waitingFor,
StateStyle = snapshot.State?.Style,
IsHidden = snapshot.IsHidden,
HealthStatus = snapshot.HealthStatus?.ToString(),
ExitCode = snapshot.ExitCode,
CreatedAt = snapshot.CreationTimeStamp,
StartedAt = snapshot.StartTimeStamp,
StoppedAt = snapshot.StopTimeStamp,
Urls = urls,
Relationships = relationships,
HealthReports = healthReports,
Volumes = volumes,
EnvironmentVariables = environmentVariables,
Properties = properties,
McpServer = mcpServer,
Commands = commands
};
}
/// <summary>
/// Redacts an environment variable value when it matches a secret parameter's resolved value
/// so secrets don't leak through clients (e.g. <c>aspire describe --format json</c>).
/// </summary>
/// <remarks>
/// Matching is value-based: a value is redacted only when it is exactly equal to a resolved secret
/// parameter value. This has two known limitations:
/// <list type="bullet">
/// <item>A non-secret value that coincidentally equals a secret value is also redacted (false positive).</item>
/// <item>A secret embedded as a substring of a larger composed value (e.g. a connection string) is not
/// detected, because only exact-equality matches are redacted.</item>
/// </list>
/// </remarks>
private static string? RedactIfSecretValue(string? value, HashSet<string> secretParameterValues)
=> value is not null && secretParameterValues.Contains(value) ? null : value;
/// <summary>
/// Collects the resolved values of secret parameters reachable from the application model so they can be
/// redacted from data sent to clients. Only values that have already been resolved are included; this never
/// blocks waiting for interactive parameter resolution. Resolved values are accumulated add-only in the
/// AppHost-scoped <see cref="SecretRedactionHistory"/> (which <c>ParameterProcessor</c> also populates at
/// assignment time), so a value a parameter has since been reassigned away from stays redacted while an older
/// snapshot can still carry it.
/// </summary>
private async Task<HashSet<string>> GetResolvedSecretParameterValuesAsync(CancellationToken cancellationToken)
{
// Resolve the current value of each accumulated secret parameter (peek-only; never blocks on interactive
// resolution), then merge into the AppHost-scoped add-only value history below.
var resolvedThisPass = new List<string>();
foreach (var parameter in await GetSecretParametersAsync(cancellationToken).ConfigureAwait(false))
{
if (parameter.WaitForValueTcs is { } waitForValueTcs)
{
// Run mode: peek at the resolved value without waiting for resolution.
if (waitForValueTcs.Task is { IsCompletedSuccessfully: true } valueTask &&
valueTask.Result is { Length: > 0 } value)
{
resolvedThisPass.Add(value);
}
}
else
{
try
{
if (parameter.ValueInternal is { Length: > 0 } value)
{
resolvedThisPass.Add(value);
}
}
catch
{
// The parameter's value isn't available (e.g. missing configuration); nothing to redact.
}
}
}
// Accumulate this pass's resolved secret strings add-only and return everything seen so far. A parameter's
// value can be replaced in place (the runtime "Set parameter" path swaps its completed WaitForValueTcs),
// so re-resolving a retained parameter later yields only the new value; keeping every value we have ever
// resolved ensures a still-current snapshot carrying the previous value is still redacted.
return serviceProvider.GetRequiredService<SecretRedactionHistory>().AddValuesAndSnapshot(resolvedThisPass);
}
/// <summary>
/// Gets the set of secret <see cref="ParameterResource"/> instances reachable from the application
/// model, including parameters that are only referenced by another resource rather than registered
/// as a top-level resource.
/// </summary>
/// <remarks>
/// The set includes generated parameters (such as the password created by <c>AddPostgres</c>) that are
/// referenced by a resource but never registered in the model, which enumerating
/// <c>appModel.Resources.OfType<ParameterResource>()</c> alone would miss and leak in plaintext
/// (https://github.com/microsoft/aspire/issues/19241).
/// <para>
/// Discovery is <em>peek-only</em>: it reads callback results that DCP already resolved and cached while
/// starting the resource (<see cref="ResourceDependencyDiscoveryOptions.PeekCachedCallbackResultsOnly"/>) and
/// never invokes a callback itself. This matters because <c>aspire describe</c> observes live resource
/// snapshots concurrently with DCP's own cache lifecycle: DCP forgets and re-evaluates a resource's callbacks
/// on restart (see <c>DcpExecutor.ForgetCachedCallbackResults</c>). Invoking a callback from here would run it
/// with the client's cancellation token and could cache a canceled or faulted task that DCP would then reuse on
/// the resource's execution path. A running resource can only appear in a snapshot after DCP has resolved and
/// cached its values, so peeking still observes every secret that a snapshot could expose.
/// </para>
/// <para>
/// The discovered set is merged into the AppHost-scoped, add-only <see cref="SecretRedactionHistory"/>: it only
/// ever grows for the life of the AppHost and is shared across connections. A restart can change which secret a
/// resource references, and a still-in-flight snapshot from the prior incarnation can carry the previous value,
/// so the redaction set must never shrink or that value would be emitted in plaintext.
/// </para>
/// </remarks>
private async Task<IReadOnlyList<ParameterResource>> GetSecretParametersAsync(CancellationToken cancellationToken)
{
var history = serviceProvider.GetRequiredService<SecretRedactionHistory>();
if (serviceProvider.GetService<DistributedApplicationModel>() is not { } appModel)
{
// No model resolved yet; return the secrets accumulated so far without adding any.
return history.AddParametersAndSnapshot([]);
}
var executionContext = serviceProvider.GetRequiredService<DistributedApplicationExecutionContext>();
// Peek at the callback results DCP cached while starting each resource; never invoke a callback. This
// observes the same referenced resources that produced the running snapshot without racing DCP's cache
// lifecycle or running stateful callbacks with the client's cancellation token.
var discoveryOptions = new ResourceDependencyDiscoveryOptions
{
DiscoveryMode = ResourceDependencyDiscoveryMode.Recursive,
PeekCachedCallbackResultsOnly = true
};
// Parameter resources referenced by annotations are not registered in the model, so they are not
// subject to its unique-name constraint. Collect by reference to preserve distinct same-named secrets.
var secretParameters = new HashSet<ParameterResource>(ReferenceEqualityComparer.Instance);
foreach (var parameter in appModel.Resources.OfType<ParameterResource>())
{
if (parameter.Secret)
{
secretParameters.Add(parameter);
}
}
// Compute the transitive dependency closure of every resource in a single multi-root walk. It shares
// one visited set across all roots, so each resource's (execution-cached) callbacks are read at most
// once. Discovering per resource instead would repeat the traversal for every resource and make the
// initial WatchAsync stream — which emits one event per resource — do quadratic work.
IReadOnlySet<IResource> dependencies;
try
{
dependencies = await ResourceExtensions.GetDependenciesAsync(appModel.Resources, executionContext, discoveryOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Fail closed at this confidentiality boundary. Peek-only discovery does not invoke callbacks, so a
// failure here is unexpected; if it does happen the redaction set is incomplete and a snapshot built
// from it could expose a secret in plaintext, so propagate instead of continuing with a partial set.
// Cancellation is intentionally not caught here so it surfaces as cancellation rather than a leak.
logger.LogDebug(ex, "Failed to compute resource dependencies while collecting secret parameters for redaction.");
throw;
}
foreach (var parameter in dependencies.OfType<ParameterResource>())
{
if (parameter.Secret)
{
secretParameters.Add(parameter);
}
}
// Merge this pass's discoveries into the add-only history and return everything seen so far, so a secret
// referenced by an earlier incarnation stays redacted even after a restart re-points the resource.
return history.AddParametersAndSnapshot(secretParameters);
}
private static ResourceSnapshotCommandArgument CreateCommandArgument(InteractionInput input)
{
return new ResourceSnapshotCommandArgument
{
Name = input.Name,
Label = input.Label,
Description = input.Description,
EnableDescriptionMarkdown = input.EnableDescriptionMarkdown,
InputType = input.InputType.ToString(),
Required = input.Required,
Placeholder = input.Placeholder,
Value = input.Value,
Options = CreateOptionsDictionary(input.Options),
AllowCustomChoice = input.AllowCustomChoice,
Disabled = input.Disabled,
MaxLength = input.MaxLength,
DynamicLoading = CreateDynamicLoadingMetadata(input.DynamicLoading)
};
}
private static JsonNode? ConvertPropertyValueToJsonNode(object? value)
{
return value switch
{
null => null,
JsonNode jsonNode => jsonNode.DeepClone(),
string stringValue => JsonValue.Create(stringValue),
bool boolValue => JsonValue.Create(boolValue),
byte byteValue => JsonValue.Create(byteValue),
sbyte sbyteValue => JsonValue.Create(sbyteValue),
short shortValue => JsonValue.Create(shortValue),
ushort ushortValue => JsonValue.Create(ushortValue),
int intValue => JsonValue.Create(intValue),
uint uintValue => JsonValue.Create(uintValue),
long longValue => JsonValue.Create(longValue),
ulong ulongValue => JsonValue.Create(ulongValue),
float floatValue => JsonValue.Create(floatValue),
double doubleValue => JsonValue.Create(doubleValue),
decimal decimalValue => JsonValue.Create(decimalValue),
IEnumerable<KeyValuePair<string, string>> properties => ConvertNameValuePropertyToJsonObject(properties),
System.Collections.IEnumerable enumerable => ConvertEnumerablePropertyValueToJsonArray(enumerable),
_ => JsonValue.Create(value.ToString())
};
}
private static JsonObject ConvertNameValuePropertyToJsonObject(IEnumerable<KeyValuePair<string, string>> properties)
{
var jsonObject = new JsonObject();
foreach (var property in properties)
{
jsonObject[property.Key] = property.Value;
}
return jsonObject;
}
private static JsonNode? ConvertPropertyValueToLegacyJsonNode(object? value)
{
var stringValue = ConvertPropertyValueToString(value);
return stringValue is null ? null : JsonValue.Create(stringValue);
}
private static string? ConvertPropertyValueToString(object? value)
{
return value switch
{
null => null,
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var stringValue) => stringValue,
JsonValue jsonValue when jsonValue.TryGetValue<bool>(out var boolValue) => boolValue.ToString(CultureInfo.InvariantCulture),
JsonValue jsonValue when jsonValue.TryGetValue<IFormattable>(out var formattableValue) => formattableValue.ToString(null, CultureInfo.InvariantCulture),
JsonNode jsonNode => jsonNode.ToJsonString(),
string stringValue => stringValue,
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
System.Collections.IEnumerable enumerable => ConvertEnumerablePropertyValueToString(enumerable),
_ => value.ToString()
};
}
private static string ConvertEnumerablePropertyValueToString(System.Collections.IEnumerable enumerable)
{
var values = new List<string>();
foreach (var value in enumerable)
{
if (ConvertPropertyValueToString(value) is { } stringValue)
{
values.Add(stringValue);
}
}
return string.Join(',', values);
}
private static JsonArray ConvertEnumerablePropertyValueToJsonArray(System.Collections.IEnumerable enumerable)
{
var array = new JsonArray();
foreach (var value in enumerable)
{
array.Add(ConvertPropertyValueToJsonNode(value));
}
return array;
}
private static bool SupportsJsonResourceProperties(string[]? clientCapabilities)
{
return clientCapabilities?.Contains(AuxiliaryBackchannelCapabilities.V3, StringComparer.Ordinal) == true;
}
private static string[]? GetStringArrayPropertyValue(object? value)
{
return value switch
{
null => null,
string s => [s],
IEnumerable<string> strings => strings.Where(static s => !string.IsNullOrEmpty(s)).ToArray(),
IEnumerable<object> objects => objects.OfType<string>().Where(static s => !string.IsNullOrEmpty(s)).ToArray(),
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().OfType<string>().Where(static s => !string.IsNullOrEmpty(s)).ToArray(),
_ => null
} is { Length: > 0 } values
? values
: null;
}
private static Dictionary<string, string?>? CreateOptionsDictionary(IReadOnlyList<KeyValuePair<string, string>>? options)
{
if (options is null)
{
return null;
}
var result = new Dictionary<string, string?>();
foreach (var option in options)
{
result[option.Key] = option.Value;
}
return result;
}
private static ResourceSnapshotCommandArgumentDynamicLoading? CreateDynamicLoadingMetadata(InputLoadOptions? dynamicLoading)
{
return dynamicLoading is null
? null
: new ResourceSnapshotCommandArgumentDynamicLoading
{
AlwaysLoadOnStart = dynamicLoading.AlwaysLoadOnStart,
DependsOnInputs = dynamicLoading.DependsOnInputs?.ToArray()
};
}
/// <summary>
/// Watches for resource log output and streams log lines to the client.
/// </summary>
/// <param name="resourceName">Optional resource name. If null, streams logs from all resources (only valid with follow=true).</param>
/// <param name="follow">If true, continuously streams logs. If false, returns existing logs and completes.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of log lines.</returns>
public async IAsyncEnumerable<ResourceLogLine> GetResourceLogsAsync(
string? resourceName = null,
bool follow = false,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var logLine in GetResourceLogsAsync(
"GetResourceLogsAsync",
null,
resourceName,
follow,
includeHidden: true,
search: null,
tail: null,
cancellationToken).ConfigureAwait(false))
{
yield return logLine;
}
}
private async IAsyncEnumerable<ResourceLogBatch> GetResourceLogBatchesAsync(
string rpcMethodName,
BackchannelTraceContext? traceContext,
string? resourceName,
bool follow,
bool includeHidden,
string? search,
int? tail,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var maxBatchSize = follow ? 1 : 256;
var logLines = GetResourceLogsAsync(
rpcMethodName,
traceContext,
resourceName,
follow,
includeHidden,
search,
tail,
cancellationToken);
await foreach (var batch in logLines.GetBatchesAsync(maxBatchSize, cancellationToken).ConfigureAwait(false))
{
yield return new ResourceLogBatch { Lines = batch };
}
}
private async IAsyncEnumerable<ResourceLogLine> GetResourceLogsAsync(
string rpcMethodName,
BackchannelTraceContext? traceContext,
string? resourceName,
bool follow,
bool includeHidden,
string? search,
int? tail,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var resourceLoggerService = serviceProvider.GetRequiredService<ResourceLoggerService>();
var appModel = serviceProvider.GetRequiredService<DistributedApplicationModel>();
var notificationService = serviceProvider.GetRequiredService<ResourceNotificationService>();
using var activity = profilingTelemetry.StartJsonRpcServerCall(rpcMethodName, streaming: true, traceContext);
var yieldedCount = 0;
using var logStreamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var logStreamCancellationToken = logStreamCts.Token;
Task? completeWriterTask = null;
try
{
// Resolve logical resource names to runtime resource ids before reading logs.
// Replicated resources can produce multiple ids for a single app model resource.
var resourcesToLog = resourceName is not null
? ResolveResourceIds(appModel, resourceName)
: ResolveAllResourceIds(appModel);
// IncludeHidden only filters the all-resource stream. A named resource request is
// treated as an explicit request for that resource, even when the resource is hidden.
if (!includeHidden && resourceName is null)
{
resourcesToLog = resourcesToLog
.Where(resolvedResourceName => !IsHiddenResource(notificationService, resolvedResourceName))
.ToList();
}
if (resourceName is not null && resourcesToLog.Count == 0)
{
logger.LogWarning("Resource '{ResourceName}' not found. No logs will be returned.", resourceName);
yield break;
}
if (resourcesToLog.Count == 0)
{
yield break;
}
// Server-side tailing only applies to finite snapshots for a single resource. Follow
// streams return new log lines, so any Tail value on a follow request is ignored here.
// For multiple resources, the CLI needs the parsed/merged ordering across resources
// before it can choose the final tail entries without changing observable output order.
var serverTailLineCount = !follow && tail.GetValueOrDefault() > 0 && resourcesToLog.Count == 1
? tail.GetValueOrDefault()
: 0;
// Queue<T> capacity only preallocates storage; the dequeue/enqueue logic below
// enforces the fixed-size tail window.
Queue<ResourceLogLine>? tailBuffer = serverTailLineCount > 0 ? new Queue<ResourceLogLine>(serverTailLineCount) : null;
// Read each resource in parallel and filter before writing to the JSON-RPC stream.
// This keeps large non-matching log histories inside the AppHost instead of forcing
// the CLI to transfer and parse them just to discard them.
var channel = Channel.CreateUnbounded<ResourceLogLine>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
var tasks = new List<Task>();
foreach (var name in resourcesToLog)
{
var task = Task.Run(async () =>
{
try
{
var logStream = follow
? resourceLoggerService.WatchAsync(name)
: resourceLoggerService.GetAllAsync(name);
await foreach (var batch in logStream.WithCancellation(logStreamCancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch)
{
var resourceLogLine = new ResourceLogLine
{
ResourceName = name,
LineNumber = logLine.LineNumber,
Content = logLine.Content,
IsError = logLine.IsErrorMessage
};
// Search on the server as a transport optimization. The CLI applies
// its parsed-log filter again so older AppHosts and display-name edge
// cases keep the same output.
if (!MatchesSearch(resourceLogLine, search))
{
continue;
}
await channel.Writer.WriteAsync(resourceLogLine, logStreamCancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Expected when cancelled
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error streaming logs for resource {ResourceName}", name);
}
}, logStreamCancellationToken);
tasks.Add(task);
}
completeWriterTask = Task.WhenAll(tasks).ContinueWith(_ => channel.Writer.Complete(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
await foreach (var logLine in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
if (tailBuffer is not null)
{
if (tailBuffer.Count == serverTailLineCount)
{
tailBuffer.Dequeue();
}
tailBuffer.Enqueue(logLine);
continue;
}
if (yieldedCount == 0)
{
activity.AddJsonRpcStreamFirstItemEvent();
}
yieldedCount++;
yield return logLine;
}
if (tailBuffer is not null)
{
foreach (var logLine in tailBuffer)
{
if (yieldedCount == 0)
{
activity.AddJsonRpcStreamFirstItemEvent();
}
yieldedCount++;
yield return logLine;
}
}
activity.AddJsonRpcStreamCompletedEvent();
}
finally
{
// Consumers can stop enumerating a follow stream without cancelling the RPC token.
// Cancel the per-resource producers so they don't keep watching logs after the
// JSON-RPC stream has ended.
await logStreamCts.CancelAsync().ConfigureAwait(false);
if (completeWriterTask is not null)
{
await completeWriterTask.ConfigureAwait(false);
}
activity.SetJsonRpcStreamItemCount(yieldedCount);
}
}
private static bool IsHiddenResource(ResourceNotificationService notificationService, string resourceName)
{
return notificationService.TryGetCurrentState(resourceName, out var resourceEvent) && resourceEvent.Snapshot.IsHidden;
}
private static bool MatchesSearch(ResourceLogLine logLine, string? search)
{
if (string.IsNullOrEmpty(search))
{
return true;
}
if (logLine.Content.Contains(search, StringComparisons.FullTextSearch) ||
logLine.ResourceName.Contains(search, StringComparisons.FullTextSearch))
{
return true;
}
// ResourceLoggerService stores raw lines like:
// 2000-12-29T20:59:59.0000000Z Re\u001b[31mady
// Strip ANSI control sequences for the server-side pre-filter so a
// visible-text search for "Ready" does not drop the line before the CLI
// can apply its parsed-log search semantics.
return AnsiParser.StripControlSequences(logLine.Content)
.Contains(search, StringComparisons.FullTextSearch);
}
/// <summary>
/// Invokes a tool on the MCP server exposed by a resource annotated with <see cref="McpServerEndpointAnnotation"/>.
/// </summary>
/// <param name="resourceName">The resource name.</param>
/// <param name="toolName">The tool name to invoke.</param>
/// <param name="arguments">Tool arguments.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A JSON representation of the MCP <see cref="CallToolResult"/>.</returns>
public async Task<CallToolResult> CallResourceMcpToolAsync(
string resourceName,
string toolName,
Dictionary<string, object?> arguments,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentException.ThrowIfNullOrWhiteSpace(toolName);
var appModel = serviceProvider.GetRequiredService<DistributedApplicationModel>();
var resource = appModel.Resources
.OfType<IResourceWithEndpoints>()
.FirstOrDefault(r => string.Equals(r.Name, resourceName, StringComparisons.ResourceName));
if (resource is null)
{
throw new InvalidOperationException($"Resource '{resourceName}' not found.");
}
if (!resource.TryGetLastAnnotation<McpServerEndpointAnnotation>(out var annotation))
{
throw new InvalidOperationException($"Resource '{resourceName}' does not have an MCP endpoint annotation.");
}
var endpointUri = await annotation.EndpointUrlResolver(resource, cancellationToken).ConfigureAwait(false);
if (endpointUri is null)
{
throw new InvalidOperationException($"MCP endpoint for resource '{resourceName}' is not available.");
}
var transport = CreateHttpClientTransport(endpointUri);
McpClient? mcpClient = null;
try
{
mcpClient = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to create MCP client.");
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Invoking tool {Name} with arguments {Arguments}", toolName, JsonSerializer.Serialize(arguments));
}
var result = await mcpClient.CallToolAsync(toolName, arguments, cancellationToken: cancellationToken).ConfigureAwait(false);
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Result: {Result}", JsonSerializer.Serialize(result));
}
return result;
}
catch (Exception ex)
{
logger.LogError(ex, "Error invoking tool {ToolName} on resource {ResourceName}", toolName, resourceName);
throw;
}
finally
{
if (mcpClient is not null)
{
await mcpClient.DisposeAsync().ConfigureAwait(false);
}
await transport.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Requests the AppHost to stop gracefully. The stop is initiated asynchronously in the background.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// A task that completes immediately after initiating the stop request. The actual stop occurs asynchronously.
/// </returns>
public Task StopAppHostAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken; // Unused but kept for API consistency
logger.LogDebug("Received request to stop AppHost");
// Start a background task to delay the stop by 500ms to allow the RPC response to be sent
_ = Task.Run(async () =>
{
try
{
await Task.Delay(500, CancellationToken.None).ConfigureAwait(false);
// Cancel inflight RPC calls in AppHostRpcTarget before stopping
var appHostRpcTarget = serviceProvider.GetService<AppHostRpcTarget>();
appHostRpcTarget?.CancelInflightRpcCalls();
var lifetime = serviceProvider.GetService<IHostApplicationLifetime>();
if (lifetime is not null)
{
logger.LogDebug("Stopping AppHost application");
lifetime.StopApplication();
}
else
{
logger.LogWarning("IHostApplicationLifetime not found, cannot stop AppHost");
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error while stopping AppHost");
}
}, CancellationToken.None);
return Task.CompletedTask;
}
private async Task<Tool[]?> TryListToolsAsync(Uri endpointUri, CancellationToken cancellationToken)
{
var transport = CreateHttpClientTransport(endpointUri);
using var timeoutCts = new CancellationTokenSource(s_mcpDiscoveryTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: linked.Token).ConfigureAwait(false);
try
{
var toolsList = await mcpClient.ListToolsAsync(cancellationToken: linked.Token).ConfigureAwait(false);
return toolsList.Select(c => c.ProtocolTool).ToArray();
}
finally
{
await mcpClient.DisposeAsync().ConfigureAwait(false);
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to list tools from MCP endpoint {EndpointUri}", endpointUri);
return null;
}
finally
{
await transport.DisposeAsync().ConfigureAwait(false);
}
}
private HttpClientTransport CreateHttpClientTransport(Uri endpointUri)
{
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
var httpClient = httpClientFactory?.CreateClient() ?? new HttpClient();
return new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = endpointUri },
httpClient,
serviceProvider.GetRequiredService<ILoggerFactory>(),
ownsHttpClient: true);
}
#endregion
/// <summary>
/// Streams AppHost log entries from the hosting process.
/// Delegates to <see cref="AppHostRpcTarget.GetAppHostLogEntriesAsync"/>.
/// </summary>
public IAsyncEnumerable<BackchannelLogEntry> GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default)
{
var rpcTarget = serviceProvider.GetRequiredService<AppHostRpcTarget>();
return rpcTarget.GetAppHostLogEntriesAsync(cancellationToken: cancellationToken);
}
/// <summary>
/// Converts a JsonElement to its underlying CLR type for proper serialization.
/// </summary>
private static object? ConvertJsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => ConvertJsonNumber(element),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElementToObject).ToArray(),
JsonValueKind.Object => element.EnumerateObject().ToDictionary(p => p.Name, p => ConvertJsonElementToObject(p.Value)),
_ => element.Clone()
};
}
private static object ConvertJsonNumber(JsonElement element)
{
// Try integer types first
if (element.TryGetInt32(out var i32))
{
return i32;
}
if (element.TryGetInt64(out var i64))
{
return i64;
}
// Fall back to double for floating point
return element.GetDouble();
}
/// <summary>
/// Resolves a resource name (logical or resolved instance name) to the list of matching resource IDs.
/// Matches by logical name first (returning all instances), then falls back to matching a specific
/// resolved instance name like "apiservice-abc123".
/// </summary>
private static List<string> ResolveResourceIds(DistributedApplicationModel appModel, string resourceName)
{
foreach (var resource in appModel.Resources)
{
var resolvedNames = resource.GetResolvedResourceNames();
if (string.Equals(resource.Name, resourceName, StringComparisons.ResourceName))
{
return [.. resolvedNames];
}
var matchedInstance = resolvedNames.FirstOrDefault(n => string.Equals(n, resourceName, StringComparisons.ResourceName));
if (matchedInstance is not null)
{
return [matchedInstance];
}
}
return [];
}
/// <summary>
/// Returns the resolved resource IDs for all resources in the application model.
/// </summary>
private static List<string> ResolveAllResourceIds(DistributedApplicationModel appModel)
{
var result = new List<string>();
foreach (var resource in appModel.Resources)
{
result.AddRange(resource.GetResolvedResourceNames());
}
return result;
}
}