Add implementation of Aspire service (#41319)

This commit is contained in:
Tomáš Matoušek 2024-06-05 11:17:27 -07:00 коммит произвёл GitHub
Родитель 9ac963c52f
Коммит 24855ca483
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
19 изменённых файлов: 1067 добавлений и 0 удалений

Просмотреть файл

@ -76,6 +76,7 @@
/test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/ @dotnet/aspnet-blazor-eng
/src/BuiltInTools/* @tmat @arkalyanms @dotnet/roslyn-ide
/src/BuiltInTools/BrowserRefresh @dotnet/aspnet-blazor-eng
/src/BuiltInTools/AspireService @BillHiebert @dotnet/aspnet-blazor-eng
# Compatibility tools owned by runtime team
/src/Compatibility/ @dotnet/area-infrastructure-libraries

Просмотреть файл

@ -36,6 +36,7 @@
<IsShippingPackage>false</IsShippingPackage>
<SdkTargetFramework>net9.0</SdkTargetFramework>
<ToolsetTargetFramework>$(SdkTargetFramework)</ToolsetTargetFramework>
<VisualStudioServiceTargetFramework>net8.0</VisualStudioServiceTargetFramework>
<!-- VS for Mac may run on a lower version of .NET than the SDK is targeting, but needs to load the resolvers. So the resolvers and dependencies
may target a lower version of .NET -->

11
sdk.sln
Просмотреть файл

@ -505,6 +505,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Tools.Test
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EndToEnd-Installer.Tests", "test\EndToEnd-Installer.Tests\EndToEnd-Installer.Tests.csproj", "{149E3D40-8115-4965-9305-5A1ADBF04899}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.WebTools.AspireService.Package", "src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.Package.csproj", "{19014C60-F87C-4CC7-AC0F-C41B6126EBCE}"
EndProject
Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Microsoft.WebTools.AspireService", "src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.shproj", "{94C8526E-DCC2-442F-9868-3DD0BA2688BE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -963,6 +967,10 @@ Global
{149E3D40-8115-4965-9305-5A1ADBF04899}.Debug|Any CPU.Build.0 = Debug|Any CPU
{149E3D40-8115-4965-9305-5A1ADBF04899}.Release|Any CPU.ActiveCfg = Release|Any CPU
{149E3D40-8115-4965-9305-5A1ADBF04899}.Release|Any CPU.Build.0 = Release|Any CPU
{19014C60-F87C-4CC7-AC0F-C41B6126EBCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{19014C60-F87C-4CC7-AC0F-C41B6126EBCE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{19014C60-F87C-4CC7-AC0F-C41B6126EBCE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{19014C60-F87C-4CC7-AC0F-C41B6126EBCE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -1140,6 +1148,8 @@ Global
{21C21975-84C1-4A24-8E21-F7EC790A4584} = {580D1AE7-AA8F-4912-8B76-105594E00B3B}
{6E690AC5-8D00-419F-B5BC-C29D099EDE59} = {580D1AE7-AA8F-4912-8B76-105594E00B3B}
{149E3D40-8115-4965-9305-5A1ADBF04899} = {580D1AE7-AA8F-4912-8B76-105594E00B3B}
{19014C60-F87C-4CC7-AC0F-C41B6126EBCE} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91}
{94C8526E-DCC2-442F-9868-3DD0BA2688BE} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6}
@ -1147,6 +1157,7 @@ Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{03c5a84a-982b-4f38-ac73-ab832c645c4a}*SharedItemsImports = 5
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{0a3c9afd-f6e6-4a5d-83fb-93bf66732696}*SharedItemsImports = 5
src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{94c8526e-dcc2-442f-9868-3dd0ba2688be}*SharedItemsImports = 13
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{9d36039f-d0a1-462f-85b4-81763c6b02cb}*SharedItemsImports = 13
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{a9103b98-d888-4260-8a05-fa36f640698a}*SharedItemsImports = 5
EndGlobalSection

Просмотреть файл

@ -0,0 +1,414 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.WebTools.AspireServer.Contracts;
using Microsoft.WebTools.AspireServer.Helpers;
using Microsoft.WebTools.AspireServer.Models;
using IAsyncDisposable = System.IAsyncDisposable;
namespace Microsoft.WebTools.AspireServer;
/// <summary>
/// Implementation of the AspireServerService. A new instance of this service will be created for each
/// each call to IServiceBroker.CreateProxy()
/// </summary>
internal partial class AspireServerService : IAsyncDisposable
{
public const string DebugSessionPortEnvVar = "DEBUG_SESSION_PORT";
public const string DebugSessionTokenEnvVar = "DEBUG_SESSION_TOKEN";
public const string DebugSessionServerCertEnvVar = "DEBUG_SESSION_SERVER_CERTIFICATE";
public const int PingIntervalInSeconds = 5;
private readonly IAspireServerEvents _aspireServerEvents;
private readonly Action<string>? _tracer;
private readonly string _currentSecret;
private readonly string _displayName;
private readonly CancellationTokenSource _shutdownCancellationTokenSource = new();
private readonly int _port;
private readonly X509Certificate2 _certificate;
private readonly string _certificateEncodedBytes;
private readonly SemaphoreSlim _webSocketAccess = new(1);
private readonly SocketConnectionManager _socketConnectionManager = new();
private static readonly char[] s_charSeparator = { ' ' };
private int _isListening;
public static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)
}
};
public AspireServerService(IAspireServerEvents aspireServerEvents, string displayName, Action<string>? tracer)
{
_aspireServerEvents = aspireServerEvents;
_tracer = tracer;
_displayName = displayName;
_port = SocketUtilities.GetNextAvailablePort();
// Set up the encryption so we can use it to generate our secret.
var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.KeySize = 128;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateKey();
_currentSecret = Convert.ToBase64String(aes.Key);
_certificate = CertGenerator.GenerateCert();
var certBytes = _certificate.Export(X509ContentType.Cert);
_certificateEncodedBytes = Convert.ToBase64String(certBytes);
// Start the server
Initialize();
}
/// <inheritdoc/>
public ValueTask<List<KeyValuePair<string, string>>> GetServerConnectionEnvironmentAsync(CancellationToken cancelToken)
{
return new ValueTask<List<KeyValuePair<string, string>>>(new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>(DebugSessionPortEnvVar,$"localhost:{_port}"),
new KeyValuePair<string, string>(DebugSessionTokenEnvVar, _currentSecret),
new KeyValuePair<string, string>(DebugSessionServerCertEnvVar, _certificateEncodedBytes),
});
}
public async ValueTask SessionEndedAsync(string dcpId, string sessionId, int processId, int? exitCode, CancellationToken cancelToken)
{
var payload = new SessionChangeNotification()
{
NotificationType = NotificationType.SessionTerminated,
SessionId = sessionId,
PID = processId,
ExitCode = exitCode
};
try
{
LogTrace($"Sending SessionEndedAsync for session {sessionId}");
var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions);
await SendMessageAsync(dcpId, jsonSerialized, cancelToken);
}
catch (Exception ex)
{
// Send messageAsync can fail if the connection is lost
LogTrace($"Sending session ended failed: {ex}");
}
}
public async ValueTask SessionStartedAsync(string dcpId, string sessionId, int processId, CancellationToken cancelToken)
{
var payload = new SessionChangeNotification()
{
NotificationType = NotificationType.ProcessRestarted,
SessionId = sessionId,
PID = processId
};
try
{
LogTrace($"Sending SessionStartedAsync for session {sessionId}");
var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions);
await SendMessageAsync(dcpId, jsonSerialized, cancelToken);
}
catch (Exception ex)
{
LogTrace($"Sending session started failed: {ex}");
}
}
public async ValueTask SendLogMessageAsync(string dcpId, string sessionID, bool isStdErr, string data, CancellationToken cancelToken)
{
var payload = new SessionLogsNotification()
{
NotificationType = NotificationType.ServiceLogs,
SessionId = sessionID,
IsStdErr = isStdErr,
LogMessage = data
};
try
{
var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions);
await SendMessageAsync(dcpId, jsonSerialized, cancelToken);
}
catch (Exception ex)
{
LogTrace($"Sending service logs failed {ex}");
}
}
/// <summary>
/// Waits for a connection so that it can get the WebSocket that will be used to send messages tio the client. It accepts messages via Restful http
/// calls.
/// </summary>
private void StartListening()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.ConfigureKestrel(kestrelOptions =>
{
kestrelOptions.ListenLocalhost(_port, listenOptions =>
{
listenOptions.UseHttps(_certificate);
});
});
var app = builder.Build();
app.MapGet("/", () => _displayName);
app.MapGet(InfoResponse.Url, GetInfoAsync);
// Set up the run session endpoints
var runSessionApi = app.MapGroup(RunSessionRequest.Url);
runSessionApi.MapPut("/", RunSessionPutAsync);
runSessionApi.MapDelete("/{sessionId}", RunSessionDeleteAsync);
runSessionApi.Map(SessionNotificationBase.Url, RunSessionNotifyAsync);
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(PingIntervalInSeconds)
});
// Run the application async. It will shutdown when the cancel token is signaled
_ = app.RunAsync(_shutdownCancellationTokenSource.Token);
}
private async Task RunSessionPutAsync(HttpContext context)
{
// Check the authentication header
if (!IsValidAuthentication(context))
{
LogTrace("Authorization failure");
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
else
{
await ProcessStartSessionRequestAsync(context);
}
}
private async Task RunSessionDeleteAsync(HttpContext context, string sessionId)
{
// Check the authentication header
if (!IsValidAuthentication(context))
{
LogTrace("Authorization failure");
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
else
{
context.Response.StatusCode = await HandleStopSessionRequestAsync(context.GetDcpId(), sessionId);
}
}
private async Task GetInfoAsync(HttpContext context)
{
// Check the authentication header
if (!IsValidAuthentication(context))
{
LogTrace("Authorization failure");
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
else
{
context.Response.StatusCode = (int)HttpStatusCode.OK;
await context.Response.WriteAsJsonAsync(InfoResponse.Instance, JsonSerializerOptions, _shutdownCancellationTokenSource.Token);
}
}
private async Task RunSessionNotifyAsync(HttpContext context)
{
// Check the authentication header
if (!IsValidAuthentication(context))
{
LogTrace("Authorization failure");
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
else if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var socketTcs = new TaskCompletionSource();
// Track this connection.
_socketConnectionManager.AddSocketConnection(webSocket, socketTcs, context.GetDcpId(), context.RequestAborted);
// We must keep the middleware pipeline alive for the duration of the socket
await socketTcs.Task;
}
private void LogTrace(string traceMsg)
{
_tracer?.Invoke($"AspireServer - {traceMsg}");
}
/// <summary>
/// starts the web server running
/// </summary>
private void Initialize()
{
if (Interlocked.CompareExchange(ref _isListening, 1, 0) == 1)
{
return;
}
// Kick of the web server.
StartListening();
}
public ValueTask DisposeAsync()
{
_socketConnectionManager.Dispose();
_certificate.Dispose();
// Shutdown the app
_shutdownCancellationTokenSource.Cancel();
_shutdownCancellationTokenSource.Dispose();
return ValueTask.CompletedTask;
}
private bool IsValidAuthentication(HttpContext context)
{
// Check the authentication header
var authHeader = context.Request.Headers.Authorization;
if (authHeader.Count == 1)
{
var authTokens = authHeader[0]!.Split(s_charSeparator, StringSplitOptions.RemoveEmptyEntries);
return authTokens.Length == 2 &&
string.Equals(authTokens[0], "Bearer", StringComparison.Ordinal) &&
string.Equals(authTokens[1], _currentSecret, StringComparison.Ordinal);
}
return false;
}
private async Task ProcessStartSessionRequestAsync(HttpContext context)
{
// Get the project launch request data
var projectLaunchRequest = await context.GetProjectLaunchInformationAsync(_shutdownCancellationTokenSource.Token);
if (projectLaunchRequest is not null)
{
try
{
var sessionId = await LaunchProjectAsync(context.GetDcpId(), projectLaunchRequest);
context.Response.StatusCode = (int)HttpStatusCode.Created;
context.Response.Headers.Location = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}/{sessionId}";
}
catch (Exception ex)
{
LogTrace($"Exception thrown starting project {projectLaunchRequest.ProjectPath}: {ex}");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
await WriteResponseTextAsync(context.Response, ex, context.GetApiVersion() is not null);
}
}
else
{
// Unknown or unsupported version
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
}
}
private async Task WriteResponseTextAsync(HttpResponse response, Exception ex, bool useRichErrorResponse)
{
byte[] errorResponse;
if (useRichErrorResponse)
{
// If the exception is a webtools one, use the failure bucket strings as the error Code
string? errorCode = null;
var error = new ErrorResponse()
{
Error = new ErrorDetail { ErrorCode = errorCode, Message = ex.GetMessageFromException() }
};
await response.WriteAsJsonAsync(error, JsonSerializerOptions, _shutdownCancellationTokenSource.Token);
}
else
{
errorResponse = Encoding.UTF8.GetBytes(ex.GetMessageFromException());
response.ContentType = "text/plain";
response.ContentLength = errorResponse.Length;
await response.WriteAsync(ex.GetMessageFromException(), _shutdownCancellationTokenSource.Token);
}
}
private async Task SendMessageAsync(string dcpId,byte[] messageBytes, CancellationToken cancellationToken)
{
// Find the connection for the passed in dcpId
WebSocketConnection? connection = _socketConnectionManager.GetSocketConnection(dcpId);
if (connection is null)
{
// Most likely the connection has already gone away
LogTrace($"Send message failure: Connection with the following dcpId was not found {dcpId}");
return;
}
try
{
using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCancellationTokenSource.Token,
connection.HttpRequestAborted);
await _webSocketAccess.WaitAsync(cancelTokenSource.Token);
await connection.Socket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token);
}
catch (Exception ex)
{
// If the connection throws it almost certainly means the client has gone away, so clean up that connection
_socketConnectionManager.RemoveSocketConnection(connection);
LogTrace($"Send message failure: {ex.GetMessageFromException()}");
throw;
}
finally
{
_webSocketAccess.Release();
}
}
private async Task<int> HandleStopSessionRequestAsync(string dcpId, string sessionId)
{
bool sessionExists = await _aspireServerEvents.StopSessionAsync(dcpId, sessionId, _shutdownCancellationTokenSource.Token);
return (int)(sessionExists ? HttpStatusCode.OK : HttpStatusCode.NoContent);
}
/// <summary>
/// Called to launch the project after first creating a LaunchProfile from the sessionRequest object. Returns the sessionId
/// for the launched process. If it throws an exception most likely the project couldn't be launched
/// </summary>
private Task<string> LaunchProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo)
=> _aspireServerEvents.StartProjectAsync(dcpId, projectLaunchInfo, _shutdownCancellationTokenSource.Token).AsTask();
}

Просмотреть файл

@ -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.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.WebTools.AspireServer.Contracts;
/// <summary>
/// Interface implemented on the VS side and pass
/// </summary>
internal interface IAspireServerEvents
{
/// <summary>
/// Called when a request to stop a session is received. Returns false if the session does not exist. Note that the dcpId identifies
/// which DCP/AppHost is making the request.
/// </summary>
ValueTask<bool> StopSessionAsync(string dcpId, string sessionId, CancellationToken cancelToken);
/// <summary>
/// Called when a request to start a project is received. Returns the sessionId of the started project. Note that the dcpId identifies
/// which DCP/AppHost is making the request. The format of this string is <appHostAssemblyName>;<unique string>. The first token can
/// be used to identify the AppHost project in the solution. The 2nd is just a unique string so that running the same project multiple times
/// generates a unique dcpId. Note that for older DCP's the dcpId will be the empty string
/// </summary>
ValueTask<string> StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancelToken);
}
internal class ProjectLaunchRequest
{
public string ProjectPath { get; set; } = string.Empty;
public bool Debug { get; set; }
public IEnumerable<KeyValuePair<string, string>>? Environment { get; set; }
public IEnumerable<string>? Arguments { get; set; }
public string? LaunchProfile { get; set; }
public bool DisableLaunchProfile { get; set; }
}

Просмотреть файл

@ -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.
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace Microsoft.WebTools.AspireServer;
internal static class CertGenerator
{
public static X509Certificate2 GenerateCert()
{
const int rsaKeySize = 2048;
var rsa = RSA.Create(rsaKeySize); // Create asymmetric RSA key pair.
var req = new CertificateRequest(
"cn=debug-session.visualstudio.microsoft.com",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss
);
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("localhost");
req.CertificateExtensions.Add(sanBuilder.Build());
var cert = req.CreateSelfSigned(
DateTimeOffset.UtcNow.AddSeconds(-5),
DateTimeOffset.UtcNow.AddDays(7)
);
if (OperatingSystem.IsWindows())
{
// Workaround for Windows S/Channel requirement for storing private for the certificate on disk.
// The file will be automatically generated by the following call and disposed when the returned cert is disposed.
using (cert)
{
return new X509Certificate2(cert.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.UserKeySet);
}
}
else
{
return cert;
}
}
}

Просмотреть файл

@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
namespace Microsoft.WebTools.AspireServer.Helpers;
internal static class ExceptionExtensions
{
/// <summary>
/// Given an exception, returns a string which has concatenated the ex.message and inner exception message
/// if it exits. If it is an aggregate exception it concatenates all the exceptions that are in the aggregate
///</summary>
public static string GetMessageFromException(this Exception ex)
{
string msg = string.Empty;
if (ex is AggregateException aggException)
{
foreach (var e in aggException.Flatten().InnerExceptions)
{
if (msg == string.Empty)
{
msg = e.Message;
}
else
{
msg += " ";
msg += e.Message;
}
}
}
else
{
msg = ex.Message;
if (ex.InnerException != null)
{
msg += " ";
msg += ex.InnerException.Message;
}
}
return msg;
}
}

Просмотреть файл

@ -0,0 +1,58 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.WebTools.AspireServer.Contracts;
using Microsoft.WebTools.AspireServer.Models;
namespace Microsoft.WebTools.AspireServer.Helpers;
internal static class HttpContextExtensions
{
public const string VersionQueryString = "api-version";
public const string DCPInstanceIDHeader = "Microsoft-Developer-DCP-Instance-ID";
public static DateTime SupportedVersionAsDate = DateTime.Parse(RunSessionRequest.SupportedProtocolVersion);
public static string? GetApiVersion(this HttpContext context)
{
return context.Request.Query[VersionQueryString];
}
/// <summary>
/// Looks for the dcp instance ID header and returns the id, or the empty string
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public static string GetDcpId(this HttpContext context)
{
// return the header value.
var dcpHeader = context.Request.Headers[DCPInstanceIDHeader];
if (dcpHeader.Count == 1)
{
return dcpHeader[0]?? string.Empty;
}
return string.Empty;
}
/// <summary>
/// Deserializes the payload depending on the protocol version and returns the normalized ProjectLaunchRequest. Returns null if the
/// protocol version is not known or older. Throws if there is a serialization failure
/// </summary>
public static async Task<ProjectLaunchRequest?> GetProjectLaunchInformationAsync(this HttpContext context, CancellationToken cancelToken)
{
// Get the version querystring if there is one. Reject any requests w/o a supported version
var versionString = context.GetApiVersion();
if (versionString is not null && DateTime.TryParse(versionString, out var version) && version >= SupportedVersionAsDate)
{
var runSessionRequest = await context.Request.ReadFromJsonAsync<RunSessionRequest>(AspireServerService.JsonSerializerOptions, cancelToken);
return runSessionRequest?.ToProjectLaunchInformation();
}
// Unknown or older version.
return null;
}
}

Просмотреть файл

@ -0,0 +1,83 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.WebTools.AspireServer;
/// <summary>
/// Manages the set of active socket connections. Since it registers to be notified when a socket has gone bad,
/// it also tracks those CancellationTokenRegistration objects so they can be disposed
/// </summary>
internal class SocketConnectionManager : IDisposable
{
// Track a single connection per Dcp ID
private readonly object _socketConnectionsLock = new();
private readonly Dictionary<string, WebSocketConnection> _webSocketConnections = new(StringComparer.Ordinal);
private void CleanupSocketConnections()
{
lock (_socketConnectionsLock)
{
foreach (var connection in _webSocketConnections)
{
connection.Value.Tcs.SetResult();
connection.Value.CancelTokenRegistration.Dispose();
}
_webSocketConnections.Clear();
}
}
public void AddSocketConnection(WebSocket socket, TaskCompletionSource tcs, string dcpId, CancellationToken httpRequestAborted)
{
// We only support one connection per DCP Id, therefore if there is
// already a connection, drop that one before adding this one
lock (_socketConnectionsLock)
{
if (_webSocketConnections.TryGetValue(dcpId, out var existingConnection))
{
_webSocketConnections.Remove(dcpId);
existingConnection.Dispose();
}
// Register with the cancel token so that if the socket goes bad, we
// get notified and can remove it from our list. We need to track the registrations as well
// so we can dispose of it later
var newConnection = new WebSocketConnection(socket, tcs, dcpId, httpRequestAborted);
newConnection.CancelTokenRegistration = httpRequestAborted.Register(() =>
{
RemoveSocketConnection(newConnection);
});
_webSocketConnections[dcpId] = newConnection;
}
}
public void RemoveSocketConnection(WebSocketConnection connection)
{
lock (_socketConnectionsLock)
{
_webSocketConnections.Remove(connection.DcpId);
connection.Dispose();
}
}
public WebSocketConnection? GetSocketConnection(string dcpId)
{
lock (_socketConnectionsLock)
{
_webSocketConnections.TryGetValue(dcpId, out var connection);
return connection;
}
}
public void Dispose()
{
CleanupSocketConnections();
}
}

Просмотреть файл

@ -0,0 +1,92 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
namespace Microsoft.WebTools.AspireServer.Helpers;
internal class SocketUtilities
{
/// <summary>
/// Unsafe ports as defined by chrome (http://superuser.com/questions/188058/which-ports-are-considered-unsafe-on-chrome)
/// </summary>
private static readonly int[] s_unsafePorts = new int[] {
2049, // nfs
3659, // apple-sasl / PasswordServer
4045, // lockd
6000, // X11
6665, // Alternate IRC [Apple addition]
6666, // Alternate IRC [Apple addition]
6667, // Standard IRC [Apple addition]
6668, // Alternate IRC [Apple addition]
6669, // Alternate IRC [Apple addition]
};
/// <summary>
/// Get the next available dynamic port
/// </summary>
public static int GetNextAvailablePort()
{
var ports = GetNextAvailablePorts(1);
return ports == null ? 0 : ports[0];
}
/// <summary>
/// Get a list of available dynamic ports. Max that can be retrieved is 10
/// </summary>
public static int[]? GetNextAvailablePorts(int countOfPorts)
{
// Creates the Socket to send data over a TCP connection.
var ports = GetNextAvailablePorts(countOfPorts, AddressFamily.InterNetwork);
ports ??= GetNextAvailablePorts(countOfPorts, AddressFamily.InterNetworkV6);
return ports;
}
/// <summary>
/// Get a list of available dynamic ports for the addressFamily.
/// </summary>
private static int[]? GetNextAvailablePorts(int countOfPorts, AddressFamily addressFamily)
{
// Creates the Socket to send data over a TCP connection.
var sockets = new List<Socket>();
try
{
var ports = new int[countOfPorts];
for (int i = 0; i < countOfPorts; i++)
{
Socket socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp);
sockets.Add(socket);
IPEndPoint endPoint = new IPEndPoint(addressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0);
socket.Bind(endPoint);
var endPointUsed = (IPEndPoint?)socket.LocalEndPoint;
if (endPointUsed is not null && !s_unsafePorts.Contains(endPointUsed.Port))
{
ports[i] = endPointUsed.Port;
}
else
{ // Need to try this one again
--i;
}
}
return ports;
}
catch (SocketException)
{
}
finally
{
foreach (var socket in sockets)
{
socket.Dispose();
}
sockets.Clear();
}
return null;
}
}

Просмотреть файл

@ -0,0 +1,35 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.WebTools.AspireServer;
/// <summary>
/// Used by the SocketConnectionManager to track one socket connection. It needs to be disposed when done with it
/// </summary>
internal class WebSocketConnection : IDisposable
{
public WebSocketConnection(WebSocket socket, TaskCompletionSource tcs, string dcpId, CancellationToken httpRequestAborted)
{
Socket = socket;
Tcs = tcs;
DcpId = dcpId;
HttpRequestAborted = httpRequestAborted;
}
public WebSocket Socket { get; }
public TaskCompletionSource Tcs { get; }
public string DcpId { get; }
public CancellationToken HttpRequestAborted { get; }
public CancellationTokenRegistration CancelTokenRegistration { get; set; }
public void Dispose()
{
Tcs.SetResult();
CancelTokenRegistration.Dispose();
}
}

Просмотреть файл

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- This source package is used by Visual Studio WebTools -->
<TargetFramework>$(VisualStudioServiceTargetFramework)</TargetFramework>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DebugType>none</DebugType>
<GenerateDependencyFile>false</GenerateDependencyFile>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<!-- NuGet -->
<IsPackable>true</IsPackable>
<IsSourcePackage>true</IsSourcePackage>
<PackageId>Aspire.Tools.Service</PackageId>
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageDescription>
Package containing sources of a service that implements DCP protocol.
</PackageDescription>
<!-- Remove once https://github.com/NuGet/Home/issues/8583 is fixed -->
<NoWarn>$(NoWarn);NU5128</NoWarn>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<MSBuildAllProjects Condition="'$(MSBuildVersion)' == '' Or '$(MSBuildVersion)' &lt; '16.0'">$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
<HasSharedItems>true</HasSharedItems>
<SharedGUID>94c8526e-dcc2-442f-9868-3dd0ba2688be</SharedGUID>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<Import_RootNamespace>Microsoft.WebTools.AspireService</Import_RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)AspireServerService.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Contracts\IAspireServerEvents.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Helpers\CertGenerator.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Helpers\ExceptionExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Helpers\HttpContextExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Helpers\SocketConnectionManager.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Helpers\SocketUtilities.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Helpers\WebSocketConnection.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Models\ErrorResponse.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Models\InfoResponse.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Models\RunSessionRequest.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Models\SessionChangeNotification.cs" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="Globals">
<ProjectGuid>94c8526e-dcc2-442f-9868-3dd0ba2688be</ProjectGuid>
<MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" />
<PropertyGroup />
<Import Project="Microsoft.WebTools.AspireService.projitems" Label="Shared" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" />
</Project>

Просмотреть файл

@ -0,0 +1,27 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json.Serialization;
namespace Microsoft.WebTools.AspireServer.Models;
/// <summary>
/// Detailed error information serialized into the body of the response
/// </summary>
internal class ErrorResponse
{
[JsonPropertyName("error")]
public ErrorDetail? Error { get; set; }
}
internal class ErrorDetail
{
[JsonPropertyName("code")]
public string? ErrorCode { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("details")]
public ErrorDetail[]? MessageDetails { get; set; }
}

Просмотреть файл

@ -0,0 +1,23 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json.Serialization;
namespace Microsoft.WebTools.AspireServer.Models;
/// <summary>
/// Response when asked for /info
/// </summary>
internal class InfoResponse
{
public const string Url = "/info";
[JsonPropertyName("protocols_supported")]
public string[]? ProtocolsSupported { get; set; }
public static InfoResponse Instance = new () {ProtocolsSupported = new string[]
{
RunSessionRequest.OurProtocolVersion,
RunSessionRequest.SupportedProtocolVersion
}};
}

Просмотреть файл

@ -0,0 +1,85 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.WebTools.AspireServer.Contracts;
namespace Microsoft.WebTools.AspireServer.Models;
internal class EnvVar
{
[Required]
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string? Value { get; set; }
}
internal class LaunchConfiguration
{
[Required]
[JsonPropertyName("type")]
public string LaunchType { get; set; } = string.Empty;
[Required]
[JsonPropertyName("project_path")]
public string ProjectPath { get; set; } = string.Empty;
[JsonPropertyName("launch_profile")]
public string? LaunchProfile { get; set; }
[JsonPropertyName("disable_launch_profile")]
public bool DisableLaunchProfile { get; set; }
[JsonPropertyName("mode")]
public string LaunchMode { get; set; } = string.Empty;
}
internal class RunSessionRequest
{
public const string Url = "/run_session";
public const string VersionQuery = "api-version";
public const string OurProtocolVersion = "2024-04-23"; // This means we support socket ping-pong keepalive
public const string SupportedProtocolVersion = "2024-03-03";
public const string ProjectLaunchConfigurationType = "project";
public const string NoDebugLaunchMode = "NoDebug";
public const string DebugLaunchMode = "Debug";
[Required]
[JsonPropertyName("launch_configurations")]
public LaunchConfiguration[] LaunchConfigurations { get; set; } = Array.Empty<LaunchConfiguration>();
[JsonPropertyName("env")]
public EnvVar[] Environment { get; set; } = Array.Empty<EnvVar>();
[JsonPropertyName("args")]
public string[] Arguments { get; set; } = Array.Empty<string>();
public ProjectLaunchRequest? ToProjectLaunchInformation()
{
// Only support one launch project request. Ignoring all others
Debug.Assert(LaunchConfigurations.Length == 1, $"Unexpected number of launch configurations {LaunchConfigurations.Length}");
var projectLaunchConfig = LaunchConfigurations.FirstOrDefault(launchConfig => string.Equals(launchConfig.LaunchType, ProjectLaunchConfigurationType, StringComparison.OrdinalIgnoreCase));
if (projectLaunchConfig is null)
{
return null;
}
return new ProjectLaunchRequest()
{
ProjectPath = projectLaunchConfig.ProjectPath,
Debug = string.Equals(projectLaunchConfig.LaunchMode, DebugLaunchMode, StringComparison.OrdinalIgnoreCase),
Arguments = Arguments,
Environment = Environment.Select(envVar => new KeyValuePair<string, string>(envVar.Name, envVar.Value!)),
LaunchProfile = projectLaunchConfig.LaunchProfile,
DisableLaunchProfile = projectLaunchConfig.DisableLaunchProfile
};
}
}

Просмотреть файл

@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Microsoft.WebTools.AspireServer.Models;
internal static class NotificationType
{
public const string ProcessRestarted = "processRestarted";
public const string SessionTerminated = "sessionTerminated";
public const string ServiceLogs = "serviceLogs";
}
internal class SessionNotificationBase
{
public const string Url = "/notify";
[Required]
[JsonPropertyName("notification_type")]
public string NotificationType { get; set; } = string.Empty;
[JsonPropertyName("session_id")]
public string SessionId { get; set; } = string.Empty;
}
internal class SessionChangeNotification : SessionNotificationBase
{
[JsonPropertyName("pid")]
public int PID { get; set; }
[JsonPropertyName("exit_code")]
public int? ExitCode { get; set; }
}
internal class SessionLogsNotification : SessionNotificationBase
{
[JsonPropertyName("is_std_err")]
public bool IsStdErr { get; set; }
[JsonPropertyName("log_message")]
public string LogMessage { get; set; } = string.Empty;
}

Просмотреть файл

@ -2,6 +2,8 @@
"solution": {
"path": "..\\..\\sdk.sln",
"projects": [
"src\\BuiltInTools\\AspireService\\Microsoft.WebTools.AspireService.Package.csproj",
"src\\BuiltInTools\\AspireService\\Microsoft.WebTools.AspireService.shproj",
"src\\BuiltInTools\\BrowserRefresh\\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj",
"src\\BuiltInTools\\DotNetDeltaApplier\\Microsoft.Extensions.DotNetDeltaApplier.csproj",
"src\\BuiltInTools\\DotNetWatchTasks\\DotNetWatchTasks.csproj",