// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Dashboard.Utils;
using Aspire.Shared;
using Aspire.Shared.Model.Serialization;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Commands;
/// <summary>
/// Output format for resources command (array wrapper).
/// </summary>
// `aspire describe --format json` uses this wrapper; keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed class ResourcesOutput
{
public required ResourceJson[] Resources { get; init; }
}
[JsonSerializable(typeof(ResourcesOutput))]
[JsonSerializable(typeof(ResourceJson))]
[JsonSerializable(typeof(ResourceUrlJson))]
[JsonSerializable(typeof(ResourceVolumeJson))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(Dictionary<string, JsonNode?>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
[JsonSerializable(typeof(Dictionary<string, ResourceHealthReportJson>))]
[JsonSerializable(typeof(ResourceRelationshipJson))]
[JsonSerializable(typeof(Dictionary<string, ResourceCommandJson>))]
[JsonSerializable(typeof(ResourceCommandArgumentJson[]))]
[JsonSourceGenerationOptions(
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal sealed partial class ResourcesCommandJsonContext : JsonSerializerContext
{
private static ResourcesCommandJsonContext? s_relaxedEscaping;
private static ResourcesCommandJsonContext? s_ndjson;
/// <summary>
/// Gets a context with relaxed JSON escaping for non-ASCII character support (pretty-printed).
/// </summary>
public static ResourcesCommandJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
/// <summary>
/// Gets a context for NDJSON streaming (compact, one object per line).
/// </summary>
public static ResourcesCommandJsonContext Ndjson => s_ndjson ??= new(new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
internal sealed class DescribeCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.Monitoring;
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ResourceColorMap _resourceColorMap;
private readonly ILogger<ResourceSnapshotWatcher> _resourceSnapshotWatcherLogger;
private static readonly Argument<string?> s_resourceArgument = new("resource")
{
Description = DescribeCommandStrings.ResourceArgumentDescription,
Arity = ArgumentArity.ZeroOrOne
};
private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
private static readonly Option<bool> s_followOption = new("--follow", "-f")
{
Description = DescribeCommandStrings.FollowOptionDescription
};
private static readonly Option<OutputFormat> s_formatOption = new("--format")
{
Description = DescribeCommandStrings.JsonOptionDescription
};
private static readonly Option<bool> s_includeHiddenOption = new("--include-hidden")
{
Description = DescribeCommandStrings.IncludeHiddenOptionDescription
};
private static readonly Option<bool> s_includeDisabledCommandsOption = new("--include-disabled-commands")
{
Hidden = true
};
public DescribeCommand(
AppHostConnectionResolver connectionResolver,
ResourceColorMap resourceColorMap,
ILogger<ResourceSnapshotWatcher> resourceSnapshotWatcherLogger,
CommonCommandServices services)
: base("describe", DescribeCommandStrings.Description, services)
{
Aliases.Add("resources");
_resourceColorMap = resourceColorMap;
_connectionResolver = connectionResolver;
_resourceSnapshotWatcherLogger = resourceSnapshotWatcherLogger;
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
Options.Add(s_followOption);
Options.Add(s_formatOption);
Options.Add(s_includeHiddenOption);
Options.Add(s_includeDisabledCommandsOption);
}
protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
using var activity = Telemetry.StartDiagnosticActivity(Name);
var resourceName = parseResult.GetValue(s_resourceArgument);
var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
var follow = parseResult.GetValue(s_followOption);
var format = parseResult.GetValue(s_formatOption);
var includeHidden = parseResult.GetValue(s_includeHiddenOption);
var includeDisabledCommands = parseResult.GetValue(s_includeDisabledCommandsOption);
var result = await _connectionResolver.ResolveConnectionAsync(
passedAppHostProjectFile,
SharedCommandStrings.ScanningForRunningAppHosts,
string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, DescribeCommandStrings.SelectAppHostAction),
SharedCommandStrings.AppHostNotRunning,
cancellationToken);
if (!result.Success)
{
return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsInformation(result, InteractionService));
}
var connection = result.Connection!;
// Get dashboard URL while the watcher loads initial snapshots.
// When a specific resource is requested, always include hidden resources
// so the user can describe any resource by name.
var effectiveIncludeHidden = includeHidden || resourceName is not null;
var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
using var resourceWatcher = new ResourceSnapshotWatcher(
connection,
_resourceSnapshotWatcherLogger,
effectiveIncludeHidden,
bufferUpdates: follow);
await resourceWatcher.WaitForInitialLoadAsync(cancellationToken).ConfigureAwait(false);
var dashboardBaseUrl = TelemetryCommandHelpers.ExtractDashboardBaseUrl((await dashboardUrlsTask.ConfigureAwait(false))?.BaseUrlWithLoginToken);
// Pre-resolve colors for all resource names so that assignment is
// deterministic regardless of which resources are displayed.
var allSnapshots = resourceWatcher.GetAllResources();
_resourceColorMap.ResolveAll(allSnapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, allSnapshots)));
if (follow)
{
try
{
return CommandResult.FromExitCode(await ExecuteWatchAsync(resourceWatcher, dashboardBaseUrl, resourceName, format, includeDisabledCommands, cancellationToken));
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken || cancellationToken.IsCancellationRequested)
{
return CommandResult.Success();
}
catch (Exception ex) when (AppHostFollowDisconnectHelpers.IsExpectedDisconnect(ex))
{
if (cancellationToken.IsCancellationRequested)
{
return CommandResult.Success();
}
// Stopping or restarting the AppHost can tear down the JSON-RPC stream while
// describe --follow is active. Treat the lost watch as a normal end of stream
// rather than surfacing it as an unexpected CLI failure. Emit the status
// message on stderr so JSON output on stdout remains parseable.
AppHostFollowDisconnectHelpers.WriteStatusMessage(InteractionService, connection);
return CommandResult.Success();
}
}
else
{
return CommandResult.FromExitCode(ExecuteSnapshot(resourceWatcher.GetResources().ToList(), dashboardBaseUrl, resourceName, format, includeDisabledCommands));
}
}
private int ExecuteSnapshot(IReadOnlyList<ResourceSnapshot> snapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format, bool includeDisabledCommands)
{
// Filter by resource name if specified
if (resourceName is not null)
{
snapshots = ResourceSnapshotMapper.ResolveResources(resourceName, snapshots).ToList();
}
// Check if resource was not found
if (resourceName is not null && snapshots.Count == 0)
{
InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, DescribeCommandStrings.ResourceNotFound, resourceName));
return CliExitCodes.FailedToFindProject;
}
var resourceList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl, includeDisabledCommands: includeDisabledCommands);
if (format == OutputFormat.Json)
{
var output = new ResourcesOutput { Resources = resourceList.ToArray() };
var json = JsonSerializer.Serialize(output, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
// Structured output always goes to stdout.
InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
}
else
{
DisplayResourcesTable(snapshots, dashboardBaseUrl);
}
return CliExitCodes.Success;
}
private async Task<int> ExecuteWatchAsync(ResourceSnapshotWatcher resourceWatcher, string? dashboardBaseUrl, string? resourceName, OutputFormat format, bool includeDisabledCommands, CancellationToken cancellationToken)
{
// Cache the last displayed content per resource to avoid duplicate output.
// Values are either a string (JSON mode) or a ResourceDisplayState (non-JSON mode).
var lastDisplayedContent = new Dictionary<string, object>(StringComparers.ResourceName);
void DisplaySnapshot(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> currentSnapshots)
{
// Skip hidden resources when not included
if (!resourceWatcher.IncludeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot))
{
return;
}
// Filter by resource name if specified
if (resourceName is not null)
{
var resolved = ResourceSnapshotMapper.ResolveResources(resourceName, currentSnapshots);
if (!resolved.Any(r => string.Equals(r.Name, snapshot.Name, StringComparison.OrdinalIgnoreCase)))
{
return;
}
}
if (format == OutputFormat.Json)
{
var resourceJson = ResourceSnapshotMapper.MapToResourceJson(
snapshot,
currentSnapshots,
dashboardBaseUrl,
includeDisabledCommands: includeDisabledCommands);
// NDJSON output - compact, one object per line for streaming
var json = JsonSerializer.Serialize(resourceJson, ResourcesCommandJsonContext.Ndjson.ResourceJson);
// Skip if the JSON is identical to the last output for this resource
if (lastDisplayedContent.TryGetValue(snapshot.Name, out var lastValue) && lastValue is string lastJson && lastJson == json)
{
return;
}
lastDisplayedContent[snapshot.Name] = json;
InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
}
else
{
// Human-readable update - build display state and skip if unchanged
var displayState = BuildResourceDisplayState(snapshot, currentSnapshots);
if (lastDisplayedContent.TryGetValue(snapshot.Name, out var lastValue) && lastValue.Equals(displayState))
{
return;
}
lastDisplayedContent[snapshot.Name] = displayState;
DisplayResourceUpdate(displayState);
}
}
var initialCapture = resourceWatcher.CaptureAllResources();
var currentSnapshots = initialCapture.Resources.ToList();
var snapshotIndexes = currentSnapshots
.Select((snapshot, index) => (snapshot.Name, index))
.ToDictionary(item => item.Name, item => item.index, StringComparers.ResourceName);
foreach (var snapshot in currentSnapshots)
{
cancellationToken.ThrowIfCancellationRequested();
DisplaySnapshot(snapshot, currentSnapshots);
}
await foreach (var batch in resourceWatcher.WatchResourceSnapshotBatchesAsync(initialCapture.UpdateSequence, cancellationToken).ConfigureAwait(false))
{
var changedSnapshots = new List<ResourceSnapshot>(batch.Snapshots.Count);
foreach (var snapshot in batch.Snapshots)
{
if (snapshotIndexes.TryGetValue(snapshot.Name, out var index))
{
if (batch.IsResync && ReferenceEquals(currentSnapshots[index], snapshot))
{
continue;
}
currentSnapshots[index] = snapshot;
}
else
{
snapshotIndexes.Add(snapshot.Name, currentSnapshots.Count);
currentSnapshots.Add(snapshot);
}
changedSnapshots.Add(snapshot);
}
foreach (var snapshot in changedSnapshots)
{
DisplaySnapshot(snapshot, currentSnapshots);
}
}
return CliExitCodes.Success;
}
private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots, string? dashboardBaseUrl)
{
if (snapshots.Count == 0)
{
InteractionService.DisplayMessage(KnownEmojis.Information, "No resources found.");
return;
}
// Get display names for all resources
var orderedItems = snapshots.Select(s => (Snapshot: s, DisplayName: ResourceSnapshotMapper.GetResourceName(s, snapshots)))
.OrderBy(x => x.DisplayName)
.ToList();
var table = new Table();
table.AddBoldColumn(DescribeCommandStrings.HeaderName);
table.AddBoldColumn(DescribeCommandStrings.HeaderType);
table.AddBoldColumn(DescribeCommandStrings.HeaderState);
table.AddBoldColumn(DescribeCommandStrings.HeaderHealth);
table.AddBoldColumn(DescribeCommandStrings.HeaderURLs);
foreach (var (snapshot, displayName) in orderedItems)
{
var endpoints = snapshot.Urls.Length > 0
? string.Join(", ", OrderUrls(snapshot.Urls.Where(e => !e.IsInternal))
.Select(e => FormatEndpointUrl(e.Url, e.DisplayProperties?.DisplayName)))
: "-";
var type = snapshot.ResourceType?.EscapeMarkup() ?? "-";
var stateText = ColorState(snapshot.State);
var healthText = ColorHealth(snapshot.HealthStatus?.EscapeMarkup() ?? "-");
string nameMarkup;
if (!string.IsNullOrEmpty(dashboardBaseUrl))
{
var resourceUrl = DashboardUrls.CombineUrl(dashboardBaseUrl, DashboardUrls.ResourcesUrl(resource: snapshot.Name));
nameMarkup = MarkupHelpers.SafeLink(InteractionService, resourceUrl, displayName);
}
else
{
nameMarkup = displayName.EscapeMarkup();
}
table.AddRow(ColorResourceName(displayName, nameMarkup), type, stateText, healthText, endpoints);
}
InteractionService.DisplayRenderable(table);
}
private static ResourceDisplayState BuildResourceDisplayState(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> allResources)
{
var displayName = ResourceSnapshotMapper.GetResourceName(snapshot, allResources);
var endpoints = OrderUrls(snapshot.Urls.Where(e => !e.IsInternal))
.Select(e => (e.Url, DisplayName: e.DisplayProperties?.DisplayName ?? ""))
.ToArray();
return new ResourceDisplayState(displayName, snapshot.State, snapshot.HealthStatus, endpoints);
}
private void DisplayResourceUpdate(ResourceDisplayState state)
{
var stateText = ColorState(state.State);
var healthText = !string.IsNullOrEmpty(state.HealthStatus) ? $" ({ColorHealth(state.HealthStatus.EscapeMarkup())})" : "";
var endpointsStr = state.Endpoints.Length > 0
? $" - {string.Join(", ", state.Endpoints.Select(e => FormatEndpointUrl(e.Url, e.DisplayName)))}"
: "";
InteractionService.DisplayMarkupLine($"{ColorResourceName(state.DisplayName, $"[[{state.DisplayName.EscapeMarkup()}]]")} {stateText}{healthText}{endpointsStr}");
}
private string ColorResourceName(string name, string displayMarkup) =>
$"[{_resourceColorMap.GetColor(name)}]{displayMarkup}[/]";
private static string ColorState(string? state)
{
if (string.IsNullOrEmpty(state))
{
return "Unknown";
}
var escaped = state.EscapeMarkup();
return state.ToUpperInvariant() switch
{
"RUNNING" => $"[green]{escaped}[/]",
"FINISHED" or "EXITED" => $"[grey]{escaped}[/]",
"FAILEDTOSTART" or "FAILED" => $"[red]{escaped}[/]",
"STARTING" or "WAITING" => $"[yellow]{escaped}[/]",
_ => escaped
};
}
private static IOrderedEnumerable<ResourceSnapshotUrl> OrderUrls(IEnumerable<ResourceSnapshotUrl> urls) =>
urls.OrderByDescending(e => e.DisplayProperties?.SortOrder ?? 0)
.ThenByDescending(e => e.Url.StartsWith("https", StringComparison.OrdinalIgnoreCase))
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase);
private string FormatEndpointUrl(string url, string? displayName = null)
{
var text = !string.IsNullOrEmpty(displayName) ? displayName : url;
return KnownUnsupportedUrlSchemes.IsLinkableUrl(url)
? MarkupHelpers.SafeLink(InteractionService, url, text)
: text.EscapeMarkup();
}
private static string ColorHealth(string health) => health.ToUpperInvariant() switch
{
"HEALTHY" => $"[green]{health}[/]",
"UNHEALTHY" => $"[red]{health}[/]",
"DEGRADED" => $"[yellow]{health}[/]",
_ => health
};
/// <summary>
/// Represents the display state of a resource for deduplication during watch mode.
/// </summary>
private sealed class ResourceDisplayState(string displayName, string? state, string? healthStatus, (string Url, string DisplayName)[] endpoints) : IEquatable<ResourceDisplayState>
{
public string DisplayName { get; } = displayName;
public string? State { get; } = state;
public string? HealthStatus { get; } = healthStatus;
public (string Url, string DisplayName)[] Endpoints { get; } = endpoints;
public bool Equals(ResourceDisplayState? other) =>
other is not null &&
DisplayName == other.DisplayName &&
State == other.State &&
HealthStatus == other.HealthStatus &&
Endpoints.AsSpan().SequenceEqual(other.Endpoints);
public override bool Equals(object? obj) => Equals(obj as ResourceDisplayState);
public override int GetHashCode() =>
HashCode.Combine(DisplayName, State, HealthStatus, Endpoints.Length);
}
}