Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/Aspire.Cli/Backchannel/AppHostBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal interface IAppHostBackchannel
Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken);
Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken);
IAsyncEnumerable<CommandOutput> ExecAsync(CancellationToken cancellationToken);
IAsyncEnumerable<BackchannelLogEntry> GetResourceLogEntriesAsync(string resourceName, int lineCount, bool follow, CancellationToken cancellationToken);
void AddDisconnectHandler(EventHandler<JsonRpcDisconnectedEventArgs> onDisconnected);
}

Expand Down Expand Up @@ -214,6 +215,26 @@ public async IAsyncEnumerable<CommandOutput> ExecAsync([EnumeratorCancellation]
}
}

public async IAsyncEnumerable<BackchannelLogEntry> GetResourceLogEntriesAsync(string resourceName, int lineCount, bool follow, [EnumeratorCancellation] CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();
var rpc = await _rpcTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);

logger.LogDebug("Requesting resource log entries for {ResourceName}", resourceName);

var logEntries = await rpc.InvokeWithCancellationAsync<IAsyncEnumerable<BackchannelLogEntry>>(
"GetResourceLogEntriesAsync",
new object[] { resourceName, lineCount, follow },
cancellationToken);

logger.LogDebug("Received resource log entries async enumerable for {ResourceName}", resourceName);

await foreach (var entry in logEntries.WithCancellation(cancellationToken))
{
yield return entry;
}
}

public void AddDisconnectHandler(EventHandler<JsonRpcDisconnectedEventArgs> onDisconnected)
{
Debug.Assert(_rpcTaskCompletionSource.Task.IsCompletedSuccessfully);
Expand Down
161 changes: 161 additions & 0 deletions src/Aspire.Cli/Commands/LogCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// 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 Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Commands;

internal sealed class LogCommand : BaseCommand
{
private readonly IProjectLocator _projectLocator;
private readonly IInteractionService _interactionService;
private readonly IAnsiConsole _ansiConsole;
private readonly ILogger<LogCommand> _logger;
private readonly AspireCliTelemetry _telemetry;
private readonly IAppHostBackchannel _backchannel;

public LogCommand(
IProjectLocator projectLocator,
IInteractionService interactionService,
IAnsiConsole ansiConsole,
ILogger<LogCommand> logger,
AspireCliTelemetry telemetry,
IAppHostBackchannel backchannel,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext)
: base("log", LogCommandStrings.Description, features, updateNotifier, executionContext)
{
ArgumentNullException.ThrowIfNull(projectLocator);
ArgumentNullException.ThrowIfNull(interactionService);
ArgumentNullException.ThrowIfNull(ansiConsole);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(telemetry);
ArgumentNullException.ThrowIfNull(backchannel);

_projectLocator = projectLocator;
_interactionService = interactionService;
_ansiConsole = ansiConsole;
_logger = logger;
_telemetry = telemetry;
_backchannel = backchannel;

// Resource name argument (required)
var resourceNameArgument = new Argument<string>("resource-name");
resourceNameArgument.Description = LogCommandStrings.ResourceNameArgumentDescription;
Arguments.Add(resourceNameArgument);

// --lines|-n option
var linesOption = new Option<int>("--lines", "-n");
linesOption.Description = LogCommandStrings.LinesArgumentDescription;
linesOption.DefaultValueFactory = (_) => 100;
Options.Add(linesOption);

// --tail|-f option
var tailOption = new Option<bool>("--tail", "-f");
tailOption.Description = LogCommandStrings.TailArgumentDescription;
tailOption.DefaultValueFactory = (_) => false;
Options.Add(tailOption);

// --project|-p option
var projectOption = new Option<FileInfo?>("--project", "-p");
projectOption.Description = LogCommandStrings.ProjectArgumentDescription;
Options.Add(projectOption);
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var resourceName = parseResult.GetValue<string>("resource-name");
var lines = parseResult.GetValue<int>("--lines");
var tail = parseResult.GetValue<bool>("--tail");
var projectFile = parseResult.GetValue<FileInfo?>("--project");

// Validate arguments
if (string.IsNullOrWhiteSpace(resourceName))
{
_interactionService.DisplayError(LogCommandStrings.ResourceNameRequired);
return ExitCodeConstants.InvalidArguments;
}

if (lines <= 0)
{
_interactionService.DisplayError(LogCommandStrings.InvalidLineCount);
return ExitCodeConstants.InvalidArguments;
}

try
{
// Find the AppHost project
var appHostProject = await _projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, cancellationToken);
if (appHostProject is null)
{
return ExitCodeConstants.FailedToFindProject;
}

// Generate deterministic socket path based on project path
var socketPath = DotNetCliRunner.GetBackchannelSocketPath(appHostProject);

if (!File.Exists(socketPath))
{
_interactionService.DisplayError(LogCommandStrings.NoRunningAppHost);
return ExitCodeConstants.FailedToConnect;
}

// Connect to the backchannel
_interactionService.ShowStatus(LogCommandStrings.ConnectingToAppHost, () => { });

try
{
await _backchannel.ConnectAsync(socketPath, cancellationToken);
}
catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
{
// Clean up stale socket file
try { File.Delete(socketPath); } catch { }
_interactionService.DisplayError(LogCommandStrings.NoRunningAppHost);
return ExitCodeConstants.FailedToConnect;
}

if (tail)
{
_interactionService.DisplayMessage("📄", string.Format(CultureInfo.CurrentCulture, LogCommandStrings.StreamingLogs, resourceName));
}

// Stream the logs
await foreach (var logEntry in _backchannel.GetResourceLogEntriesAsync(resourceName, lines, tail, cancellationToken))
{
var formattedTime = logEntry.Timestamp.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
var message = $"[{formattedTime}] [{logEntry.LogLevel}] [{resourceName}] {logEntry.Message}";
_ansiConsole.WriteLine(message);
}

return ExitCodeConstants.Success;
}
catch (ProjectLocatorException ex)
{
return BaseCommand.HandleProjectLocatorException(ex, _interactionService);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// User pressed Ctrl+C
return ExitCodeConstants.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve logs");
_interactionService.DisplayError($"Failed to retrieve logs: {ex.Message}");
return ExitCodeConstants.UnknownError;
}
}
}
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public RootCommand(
DeployCommand deployCommand,
ConfigCommand configCommand,
ExecCommand execCommand,
LogCommand logCommand,
UpdateCommand updateCommand,
ExtensionInternalCommand extensionInternalCommand,
IFeatures featureFlags,
Expand All @@ -41,6 +42,7 @@ public RootCommand(
ArgumentNullException.ThrowIfNull(deployCommand);
ArgumentNullException.ThrowIfNull(updateCommand);
ArgumentNullException.ThrowIfNull(execCommand);
ArgumentNullException.ThrowIfNull(logCommand);
ArgumentNullException.ThrowIfNull(extensionInternalCommand);
ArgumentNullException.ThrowIfNull(featureFlags);
ArgumentNullException.ThrowIfNull(interactionService);
Expand Down Expand Up @@ -93,6 +95,7 @@ public RootCommand(
Subcommands.Add(publishCommand);
Subcommands.Add(configCommand);
Subcommands.Add(deployCommand);
Subcommands.Add(logCommand);
Subcommands.Add(updateCommand);
Subcommands.Add(extensionInternalCommand);

Expand Down
24 changes: 19 additions & 5 deletions src/Aspire.Cli/DotNet/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Backchannel;
Expand Down Expand Up @@ -409,7 +410,7 @@ public async Task<int> NewProjectAsync(string templateName, string name, string
cancellationToken: cancellationToken);
}

internal static string GetBackchannelSocketPath()
internal static string GetBackchannelSocketPath(FileInfo? projectFile = null)
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var aspireCliPath = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
Expand All @@ -419,9 +420,22 @@ internal static string GetBackchannelSocketPath()
Directory.CreateDirectory(aspireCliPath);
}

var uniqueSocketPathSegment = Guid.NewGuid().ToString("N");
var socketPath = Path.Combine(aspireCliPath, $"cli.sock.{uniqueSocketPathSegment}");
return socketPath;
if (projectFile is not null)
{
// Create deterministic socket path based on project path hash
var pathBytes = Encoding.UTF8.GetBytes(projectFile.FullName.ToLowerInvariant());
var hashBytes = SHA256.HashData(pathBytes);
var hashString = Convert.ToHexString(hashBytes).ToLowerInvariant()[..16]; // First 16 chars
var socketPath = Path.Combine(aspireCliPath, $"cli.sock.{hashString}");
return socketPath;
}
else
{
// Fallback to random path for compatibility
var uniqueSocketPathSegment = Guid.NewGuid().ToString("N");
var socketPath = Path.Combine(aspireCliPath, $"cli.sock.{uniqueSocketPathSegment}");
return socketPath;
}
}

public virtual async Task<int> ExecuteAsync(string[] args, IDictionary<string, string>? env, FileInfo? projectFile, DirectoryInfo workingDirectory, TaskCompletionSource<IAppHostBackchannel>? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
Expand Down Expand Up @@ -451,7 +465,7 @@ public virtual async Task<int> ExecuteAsync(string[] args, IDictionary<string, s
startInfo.ArgumentList.Add(a);
}

var socketPath = GetBackchannelSocketPath();
var socketPath = GetBackchannelSocketPath(projectFile);
if (backchannelCompletionSource is not null)
{
startInfo.EnvironmentVariables[KnownConfigNames.UnixSocketPath] = socketPath;
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/ExitCodeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ internal static class ExitCodeConstants
public const int DashboardFailure = 12;
public const int FailedToUpgradeProject = 13;
public const int CentralPackageManagementNotSupported = 14;
public const int InvalidArguments = 15;
public const int FailedToConnect = 16;
public const int UnknownError = 99;
}
1 change: 1 addition & 0 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ private static async Task<IHost> BuildApplicationAsync(string[] args)
builder.Services.AddTransient<UpdateCommand>();
builder.Services.AddTransient<DeployCommand>();
builder.Services.AddTransient<ExecCommand>();
builder.Services.AddTransient<LogCommand>();
builder.Services.AddTransient<RootCommand>();
builder.Services.AddTransient<ExtensionInternalCommand>();

Expand Down
Loading
Loading