Skip to content
Merged
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
81 changes: 44 additions & 37 deletions src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ private async Task TryConnectToSocketAsync(string socketPath, ConcurrentBag<stri

// Use the centralized factory to create the connection
// This ensures capabilities are always fetched
var connection = await AppHostAuxiliaryBackchannel.CreateFromSocketAsync(hash, socketPath, isInScope, socket, logger, cancellationToken, profilingTelemetry).ConfigureAwait(false);
var connection = await AppHostAuxiliaryBackchannel.CreateFromSocketAsync(hash, socketPath, isInScope, logger, socket, cancellationToken, profilingTelemetry).ConfigureAwait(false);

// Update isInScope based on actual appHostInfo now that we have it
connection.IsInScope = IsAppHostInScope(connection.AppHostInfo?.AppHostPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ internal async Task<ExecuteCommandResult> ExecuteCommandCoreAsync(string resourc
Arguments = arguments
};

// When non-interactive, set an AsyncLocal scope so that IInteractionService.IsAvailable
// returns false during command execution. This lets command callbacks know they should
// not attempt to prompt the user.
using var _ = nonInteractive ? InteractionService.StartNonInteractiveScope() : default;

var result = await annotation.ExecuteCommand(context).ConfigureAwait(false);
Comment thread
JamesNK marked this conversation as resolved.
if (result.Success)
{
Expand All @@ -337,7 +342,7 @@ internal async Task<ExecuteCommandResult> ExecuteCommandCoreAsync(string resourc
catch (Exception ex)
{
logger.LogError(ex, "Error executing command '{CommandName}'.", commandName);
return new ExecuteCommandResult { Success = false, Message = "Unhandled exception thrown." };
return new ExecuteCommandResult { Success = false, Message = ex.Message };
}
}

Expand Down
34 changes: 33 additions & 1 deletion src/Aspire.Hosting/InteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ internal class InteractionService : IInteractionService
{
internal const string DiagnosticId = "ASPIREINTERACTION001";

// Tracks whether the current async flow is executing in a non-interactive context,
// such as a resource command triggered by the CLI with NonInteractive=true.
// When set, IsAvailable returns false so command callbacks know not to prompt the user.
private static readonly AsyncLocal<bool> s_nonInteractiveScope = new();

private Action<Interaction>? OnInteractionUpdated { get; set; }
private readonly object _onInteractionUpdatedLock = new();
private readonly InteractionCollection _interactionCollection = new();
Expand All @@ -37,6 +42,11 @@ public bool IsAvailable
{
get
{
if (s_nonInteractiveScope.Value)
{
return false;
}

if (_distributedApplicationOptions.DisableDashboard)
{
return false;
Expand All @@ -55,6 +65,28 @@ public bool IsAvailable
}
}

/// <summary>
/// Creates a scope in which <see cref="IsAvailable"/> returns <c>false</c>.
/// The previous value is restored when the returned <see cref="IDisposable"/> is disposed.
/// </summary>
internal static NonInteractiveScope StartNonInteractiveScope() => new();

internal sealed class NonInteractiveScope : IDisposable
{
private readonly bool _previousValue;

public NonInteractiveScope()
{
_previousValue = s_nonInteractiveScope.Value;
s_nonInteractiveScope.Value = true;
}

public void Dispose()
{
s_nonInteractiveScope.Value = _previousValue;
}
}

public async Task<InteractionResult<bool>> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
options ??= MessageBoxInteractionOptions.CreateDefault();
Expand Down Expand Up @@ -490,7 +522,7 @@ private void EnsureServiceAvailable()
{
if (!IsAvailable)
{
throw new InvalidOperationException($"InteractionService is not available because the dashboard is not enabled. Use the {nameof(IsAvailable)} property to determine whether the service is available.");
throw new InvalidOperationException($"{nameof(InteractionService)} is not available because the dashboard is not enabled or because this command is running in non-interactive CLI mode.");
}
}
}
Expand Down
146 changes: 146 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/ResourceCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
using Xunit;

namespace Aspire.Cli.EndToEnd.Tests;

/// <summary>
/// End-to-end tests for aspire resource command execution.
/// </summary>
public sealed class ResourceCommandTests(ITestOutputHelper output)
{
[Fact]
[CaptureWorkspaceOnFailure]
public async Task ResourceCommand_FailsWhenInteractionServiceIsRequired()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var projectSuffix = Guid.NewGuid().ToString("N")[..6];
var projectName = $"ResourceCmdApp_{projectSuffix}";

using var workspace = TemporaryWorkspace.Create(output);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
var testBodyFailed = false;

try
{
await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost);

await auto.TypeAsync($"cd {projectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

// Read the generated apphost.cs so we can extract the #:sdk line with the
// resolved version, then replace the entire file with a minimal app host
// that has a placeholder resource and a command that uses IInteractionService.
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs");
var content = File.ReadAllText(appHostFilePath);

// Extract the first line (#:sdk directive) so the replacement uses the same SDK version.
var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r');

var newContent = $$"""
{{sdkLine}}

#pragma warning disable ASPIREINTERACTION001

var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddContainer("cache", "redis");

cache.WithCommand(
name: "needs-interaction",
displayName: "Needs interaction",
executeCommand: async context =>
{
var interactionService = (IInteractionService)context.ServiceProvider.GetService(typeof(IInteractionService))!;

try
{
// This should throw because InteractionService is not available in non-interactive mode.
// Bound the wait to avoid hanging the E2E run if behavior regresses.
_ = await interactionService.PromptInputAsync(
title: "Prompt title",
message: "Prompt message",
inputLabel: "Name",
placeHolder: "placeholder").WaitAsync(TimeSpan.FromSeconds(5));

return CommandResults.Failure("Prompt unexpectedly completed without throwing.");
}
catch (TimeoutException)
{
return CommandResults.Failure("Prompt timed out after 5 seconds.");
}
});

builder.Build().Run();
""";

File.WriteAllText(appHostFilePath, newContent);

await auto.TypeAsync("aspire start");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("aspire resource cache needs-interaction");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Failed to execute command 'needs-interaction' on resource 'cache'", timeout: TimeSpan.FromSeconds(30));
await auto.WaitUntilTextAsync("InteractionService is not available", timeout: TimeSpan.FromSeconds(30));
await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30));

await auto.TypeAsync("if [ $? -eq 0 ]; then echo RESOURCE_CMD_SUCCEEDED_UNEXPECTEDLY; else echo RESOURCE_CMD_FAILED_AS_EXPECTED; fi");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("RESOURCE_CMD_FAILED_AS_EXPECTED", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
}
catch
{
testBodyFailed = true;
throw;
}
finally
{
try
{
await auto.CaptureAspireDiagnosticsAsync(counter, workspace);
}
catch
{
// Best effort diagnostics capture.
}

try
{
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
catch
{
if (!testBodyFailed)
{
throw;
}
}
}
}
}
3 changes: 2 additions & 1 deletion tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StreamJsonRpc;

namespace Aspire.Cli.Tests.Commands;
Expand Down Expand Up @@ -686,7 +687,7 @@ public async Task<AppHostAuxiliaryBackchannel> ConnectAsync()
_disposables.Add(messageHandler);
_disposables.Add(serverStream);

return await AppHostAuxiliaryBackchannel.CreateFromSocketAsync("hash1", "socket.hash1", isInScope: true, clientSocket).DefaultTimeout();
return await AppHostAuxiliaryBackchannel.CreateFromSocketAsync("hash1", "socket.hash1", isInScope: true, NullLogger.Instance, clientSocket).DefaultTimeout();
}

public void Dispose()
Expand Down
26 changes: 26 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/ResourceCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,32 @@ public async Task ResourceCommand_DisplaysValidationErrorArgumentNamesAsCliOptio
Assert.DoesNotContain("timeoutSeconds:", error);
}

[Fact]
public async Task ResourceCommand_FailsWhenCommandUsesInteractionService()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var interactionService = new TestInteractionService();

var backchannel = new TestAppHostAuxiliaryBackchannel
{
ResourceSnapshots =
[
CreateResourceSnapshot(
"web",
CreateCommand("configure", "Configures the resource."))
]
};
await using var provider = CreateServiceProvider(workspace, outputHelper, backchannel, interactionService);

var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("""resource web configure""");

await result.InvokeAsync().DefaultTimeout();

Assert.Equal(1, backchannel.ExecuteResourceCommandCallCount);
Assert.True(backchannel.ExecuteResourceCommandOptions?.NonInteractive == true);
}

[Fact]
public async Task ResourceCommand_ForwardsCustomChoiceCommandOptionWhenAllowed()
{
Expand Down
77 changes: 77 additions & 0 deletions tests/Aspire.Hosting.Tests/InteractionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,83 @@ public void IsAvailable_InteractivityDisabledAndDashboardDisabled_ReturnsFalse()
Assert.False(interactionService.IsAvailable);
}

[Fact]
public void IsAvailable_NonInteractiveScope_ReturnsFalse()
{
var interactionService = CreateInteractionService();

Assert.True(interactionService.IsAvailable);

using (InteractionService.StartNonInteractiveScope())
{
Assert.False(interactionService.IsAvailable);
}

Assert.True(interactionService.IsAvailable);
}

[Fact]
public async Task IsAvailable_NonInteractiveScope_FlowsAcrossAsyncCalls()
{
var interactionService = CreateInteractionService();

Assert.True(interactionService.IsAvailable);

using (InteractionService.StartNonInteractiveScope())
{
Assert.False(interactionService.IsAvailable);

await Task.Yield();

// AsyncLocal should flow across await points
Assert.False(interactionService.IsAvailable);
}

Assert.True(interactionService.IsAvailable);
}

[Fact]
public void IsAvailable_NestedNonInteractiveScopes_RestoresPreviousValue()
{
var interactionService = CreateInteractionService();

Assert.True(interactionService.IsAvailable);

using (InteractionService.StartNonInteractiveScope())
{
Assert.False(interactionService.IsAvailable);

using (InteractionService.StartNonInteractiveScope())
{
Assert.False(interactionService.IsAvailable);
}

// Inner scope disposed, but outer scope still active
Assert.False(interactionService.IsAvailable);
}

Assert.True(interactionService.IsAvailable);
}

[Fact]
public void IsAvailable_NullScopeDispose_DoesNotAffectOuterScope()
{
var interactionService = CreateInteractionService();

Assert.True(interactionService.IsAvailable);

using (InteractionService.StartNonInteractiveScope())
{
Assert.False(interactionService.IsAvailable);

using var _ = default(InteractionService.NonInteractiveScope);

Assert.False(interactionService.IsAvailable);
}

Assert.True(interactionService.IsAvailable);
}

[Fact]
public async Task PromptInputAsync_ValidationCallbackInvalidData_ReturnErrors()
{
Expand Down
Loading
Loading