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
14 changes: 9 additions & 5 deletions playground/python/Python.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@

var builder = DistributedApplication.CreateBuilder(args);

builder.AddPythonApp("script-only", "../script_only", "main.py");
builder.AddPythonApp("scriptonly", "../script_only", "main.py");
builder.AddPythonApp("instrumented-script", "../instrumented_script", "main.py");

builder.AddPythonModule("fastapi-app", "../module_only", "uvicorn")
builder.AddPythonModule("fastapiapp", "../module_only", "uvicorn")
.WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8000")
.WithHttpEndpoint(targetPort: 8000)
.WithUv();

// Run the same app on another port using uvicorn directly
builder.AddPythonExecutable("fastapi-uvicorn-app", "../module_only", "uvicorn")
builder.AddPythonExecutable("fastapiuvicornapp", "../module_only", "uvicorn")
.WithDebugging()
.WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8001")
.WithHttpEndpoint(targetPort: 8001);

// Flask app using Flask module directly
builder.AddPythonModule("flask-app", "../flask_app", "flask")
builder.AddPythonModule("flaskapp", "../flask_app", "flask")
.WithEnvironment("FLASK_APP", "app:create_app")
.WithArgs(c =>
{
Expand All @@ -30,10 +30,14 @@
.WithUv();

// Uvicorn app using the AddUvicornApp method
builder.AddUvicornApp("uvicorn-app", "../uvicorn_app", "app:app")
var uvicornApp = builder.AddUvicornApp("uvicornapp", "../uvicorn_app", "app:app")
.WithUv()
.WithExternalHttpEndpoints();

builder.AddPytest("uvicorn-tests", "../uvicorn_app")
.WithReference(uvicornApp).WaitFor(uvicornApp)
.WithUv();

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
// of the dashboard. It is not required in end developer code. Comment out this code
Expand Down
1 change: 1 addition & 0 deletions playground/python/uvicorn_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async def root():
"""Root endpoint."""
return "API service is running. Navigate to /weatherforecast to see sample data."

@app.get("/weatherforecast")
async def weather_forecast():
"""Weather forecast endpoint."""
# Generate fresh data if not in cache or cache unavailable.
Expand Down
6 changes: 6 additions & 0 deletions playground/python/uvicorn_app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-grpc>=1.38.0",
"opentelemetry-instrumentation-fastapi>=0.59b0",
]

[dependency-groups]
dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
]
2 changes: 2 additions & 0 deletions playground/python/uvicorn_app/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Licensed to the .NET Foundation under one or more agreements.
# The .NET Foundation licenses this file to you under the MIT license.
46 changes: 46 additions & 0 deletions playground/python/uvicorn_app/tests/test_restapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Licensed to the .NET Foundation under one or more agreements.
# The .NET Foundation licenses this file to you under the MIT license.

"""Integration tests for the uvicorn app endpoints."""

import os

import httpx
import pytest


@pytest.fixture
def base_url():
"""Get the base URL from environment variable."""
url = os.environ.get("UVICORNAPP_HTTP")
if not url:
pytest.skip("UVICORNAPP_HTTP environment variable not set")
return url.rstrip("/")


def test_root_endpoint(base_url):
"""Test the root endpoint returns expected message."""
response = httpx.get(f"{base_url}/")
assert response.status_code == 200
assert "API service is running" in response.text


def test_weatherforecast_endpoint(base_url):
"""Test the weatherforecast endpoint returns valid forecast data."""
response = httpx.get(f"{base_url}/weatherforecast")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 5
for item in data:
assert "date" in item
assert "temperatureC" in item
assert "temperatureF" in item
assert "summary" in item


def test_health_endpoint(base_url):
"""Test the health check endpoint returns healthy status."""
response = httpx.get(f"{base_url}/health")
assert response.status_code == 200
assert response.text == "Healthy"
46 changes: 46 additions & 0 deletions playground/python/uvicorn_app/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using StreamJsonRpc;

namespace Aspire.Cli.Backchannel;

/// <summary>
/// Encapsulates communication with an AppHost via the auxiliary backchannel.
/// </summary>
internal sealed class AuxiliaryBackchannel : IAuxiliaryBackchannel
{
private readonly JsonRpc _rpc;

/// <summary>
/// Initializes a new instance of the <see cref="AuxiliaryBackchannel"/> class.
/// </summary>
/// <param name="rpc">The JSON-RPC connection to the AppHost.</param>
public AuxiliaryBackchannel(JsonRpc rpc)
{
_rpc = rpc ?? throw new ArgumentNullException(nameof(rpc));
}

/// <inheritdoc/>
public Task<AppHostInformation?> GetAppHostInformationAsync(CancellationToken cancellationToken = default)
{
return _rpc.InvokeWithCancellationAsync<AppHostInformation?>("GetAppHostInformationAsync", cancellationToken: cancellationToken);
}

/// <inheritdoc/>
public Task<DashboardMcpConnectionInfo?> GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default)
{
return _rpc.InvokeWithCancellationAsync<DashboardMcpConnectionInfo?>("GetDashboardMcpConnectionInfoAsync", cancellationToken: cancellationToken);
}

/// <inheritdoc/>
public Task<TestResults?> GetTestResultsAsync(CancellationToken cancellationToken = default)
{
return _rpc.InvokeWithCancellationAsync<TestResults?>("GetTestResultsAsync", cancellationToken: cancellationToken);
}

/// <inheritdoc/>
public Task StopAppHostAsync(CancellationToken cancellationToken = default)
{
return _rpc.InvokeWithCancellationAsync("StopAppHostAsync", cancellationToken: cancellationToken);
}
}
17 changes: 13 additions & 4 deletions src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,16 +185,19 @@ private async Task TryConnectToSocketAsync(string socketPath, CancellationToken
var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
rpc.StartListening();

// Create the auxiliary backchannel wrapper
var backchannel = new AuxiliaryBackchannel(rpc);

// Get the AppHost information
var appHostInfo = await rpc.InvokeAsync<AppHostInformation?>("GetAppHostInformationAsync").ConfigureAwait(false);
var appHostInfo = await backchannel.GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false);

// Get the MCP connection info
var mcpInfo = await rpc.InvokeAsync<DashboardMcpConnectionInfo?>("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false);
var mcpInfo = await backchannel.GetDashboardMcpConnectionInfoAsync(cancellationToken).ConfigureAwait(false);

// Determine if this AppHost is in scope of the MCP server's working directory
var isInScope = IsAppHostInScope(appHostInfo?.AppHostPath);

var connection = new AppHostConnection(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope);
var connection = new AppHostConnection(hash, socketPath, rpc, backchannel, mcpInfo, appHostInfo, isInScope);

// Set up disconnect handler
rpc.Disconnected += (sender, args) =>
Expand Down Expand Up @@ -320,11 +323,12 @@ internal sealed class AppHostConnection
/// <summary>
/// Initializes a new instance of the <see cref="AppHostConnection"/> class.
/// </summary>
public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope)
public AppHostConnection(string hash, string socketPath, JsonRpc rpc, IAuxiliaryBackchannel backchannel, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope)
{
Hash = hash;
SocketPath = socketPath;
Rpc = rpc;
Backchannel = backchannel;
McpInfo = mcpInfo;
AppHostInfo = appHostInfo;
IsInScope = isInScope;
Expand All @@ -346,6 +350,11 @@ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardM
/// </summary>
public JsonRpc Rpc { get; }

/// <summary>
/// Gets the auxiliary backchannel for communicating with the AppHost.
/// </summary>
public IAuxiliaryBackchannel Backchannel { get; }

/// <summary>
/// Gets the MCP connection information for the Dashboard.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ namespace Aspire.Cli.Backchannel;
[JsonSerializable(typeof(AppHostProjectSearchResultPoco))]
[JsonSerializable(typeof(DashboardMcpConnectionInfo))]
[JsonSerializable(typeof(AppHostInformation))]
[JsonSerializable(typeof(TestResults))]
[JsonSerializable(typeof(TestResourceResult))]
[JsonSerializable(typeof(TestResultFileInfo))]
internal partial class BackchannelJsonSerializerContext : JsonSerializerContext
{
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")]
Expand Down
38 changes: 38 additions & 0 deletions src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Cli.Backchannel;

/// <summary>
/// Interface for communicating with an AppHost via the auxiliary backchannel.
/// </summary>
internal interface IAuxiliaryBackchannel
{
/// <summary>
/// Gets information about the AppHost.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The AppHost information including the fully qualified path and process ID.</returns>
Task<AppHostInformation?> GetAppHostInformationAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Gets the Dashboard MCP connection information including endpoint URL and API token.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The MCP connection information, or null if the dashboard is not part of the application model.</returns>
Task<DashboardMcpConnectionInfo?> GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Gets the test results by waiting for all test resources to complete.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The test results.</returns>
Task<TestResults?> GetTestResultsAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Initiates an orderly shutdown of the AppHost.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the shutdown request has been sent.</returns>
Task StopAppHostAsync(CancellationToken cancellationToken = default);
}
7 changes: 7 additions & 0 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public RootCommand(
ExecCommand execCommand,
UpdateCommand updateCommand,
McpCommand mcpCommand,
TestCommand testCommand,
ExtensionInternalCommand extensionInternalCommand,
IFeatures featureFlags,
IInteractionService interactionService)
Expand All @@ -49,6 +50,7 @@ public RootCommand(
ArgumentNullException.ThrowIfNull(updateCommand);
ArgumentNullException.ThrowIfNull(execCommand);
ArgumentNullException.ThrowIfNull(mcpCommand);
ArgumentNullException.ThrowIfNull(testCommand);
ArgumentNullException.ThrowIfNull(extensionInternalCommand);
ArgumentNullException.ThrowIfNull(featureFlags);
ArgumentNullException.ThrowIfNull(interactionService);
Expand Down Expand Up @@ -122,5 +124,10 @@ public RootCommand(
Subcommands.Add(execCommand);
}

if (featureFlags.IsFeatureEnabled(KnownFeatures.TestCommandEnabled, false))
{
Subcommands.Add(testCommand);
}

}
}
Loading