diff --git a/src/Bicep.Local.Deploy.IntegrationTests/KestrelProviderExtension.cs b/src/Bicep.Local.Deploy.IntegrationTests/KestrelProviderExtension.cs index 2806a705f99..9a9819cc666 100644 --- a/src/Bicep.Local.Deploy.IntegrationTests/KestrelProviderExtension.cs +++ b/src/Bicep.Local.Deploy.IntegrationTests/KestrelProviderExtension.cs @@ -15,15 +15,22 @@ namespace Bicep.Local.Deploy.IntegrationTests; [TestClass] public class KestrelProviderExtension : ProviderExtension { - protected override async Task RunServer(string socketPath, ResourceDispatcher dispatcher, CancellationToken cancellationToken) + protected override async Task RunServer(ConnectionOptions connectionOptions, ResourceDispatcher dispatcher, CancellationToken cancellationToken) { var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(options => { - options.ListenUnixSocket(socketPath, listenOptions => + switch (connectionOptions) { - listenOptions.Protocols = HttpProtocols.Http2; - }); + case { Socket: {}, Pipe: null }: + options.ListenUnixSocket(connectionOptions.Socket, listenOptions => listenOptions.Protocols = HttpProtocols.Http2); + break; + case { Socket: null, Pipe: {} }: + options.ListenNamedPipe(connectionOptions.Pipe, listenOptions => listenOptions.Protocols = HttpProtocols.Http2); + break; + default: + throw new InvalidOperationException("Either socketPath or pipeName must be specified."); + } }); builder.Services.AddGrpc(); diff --git a/src/Bicep.Local.Deploy.IntegrationTests/ProviderExtensionTests.cs b/src/Bicep.Local.Deploy.IntegrationTests/ProviderExtensionTests.cs index 65beae1b0df..6957fa41932 100644 --- a/src/Bicep.Local.Deploy.IntegrationTests/ProviderExtensionTests.cs +++ b/src/Bicep.Local.Deploy.IntegrationTests/ProviderExtensionTests.cs @@ -3,8 +3,10 @@ using System.Diagnostics; using System.IO.Pipes; +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; +using Azure.Deployments.Expression.Expressions; using Bicep.Core.UnitTests; using Bicep.Core.UnitTests.Assertions; using Bicep.Core.UnitTests.Mock; @@ -13,6 +15,7 @@ using Bicep.Local.Extension.Rpc; using FluentAssertions; using Grpc.Core; +using Grpc.Net.Client; using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.WindowsAzure.ResourceStack.Common.Json; using Moq; @@ -27,10 +30,14 @@ namespace Bicep.Local.Deploy.IntegrationTests; [TestClass] public class ProviderExtensionTests : TestBase { - private async Task RunExtensionTest(Action registerHandlers, Func testFunc) + public enum ChannelMode { - var socketPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.tmp"); + UnixDomainSocket, + NamedPipe, + } + private async Task RunExtensionTest(string[] processArgs, Func channelBuilder, Action registerHandlers, Func testFunc) + { var testTimeout = TimeSpan.FromMinutes(1); var cts = new CancellationTokenSource(testTimeout); @@ -39,14 +46,13 @@ await Task.WhenAll( { var extension = new KestrelProviderExtension(); - await extension.RunAsync(["--socket", socketPath], registerHandlers, cts.Token); + await extension.RunAsync(processArgs, registerHandlers, cts.Token); }), Task.Run(async () => { try { - var channel = GrpcChannelHelper.CreateChannel(socketPath); - var client = new BicepExtension.BicepExtensionClient(channel); + var client = new BicepExtension.BicepExtensionClient(channelBuilder()); await GrpcChannelHelper.WaitForConnectionAsync(client, cts.Token); @@ -59,9 +65,38 @@ await Task.WhenAll( }, cts.Token)); } + private static IEnumerable GetDataSets() + { + yield return new object[] { ChannelMode.UnixDomainSocket }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Kestrel only supports named pipes on Windows + yield return new object[] { ChannelMode.NamedPipe }; + } + } + [TestMethod] - public async Task Save_request_works_as_expected() + [DynamicData(nameof(GetDataSets), DynamicDataSourceType.Method)] + public async Task Save_request_works_as_expected(ChannelMode mode) { + string[] processArgs; + Func channelBuilder; + switch (mode) + { + case ChannelMode.UnixDomainSocket: + var socketPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.tmp"); + processArgs = ["--socket", socketPath]; + channelBuilder = () => GrpcChannelHelper.CreateDomainSocketChannel(socketPath); + break; + case ChannelMode.NamedPipe: + var pipeName = $"{Guid.NewGuid()}.tmp"; + processArgs = ["--pipe", pipeName]; + channelBuilder = () => GrpcChannelHelper.CreateNamedPipeChannel(pipeName); + break; + default: + throw new NotImplementedException(); + } + JsonObject identifiers = new() { { "name", "someName" }, @@ -78,6 +113,8 @@ public async Task Save_request_works_as_expected() null))); await RunExtensionTest( + processArgs, + channelBuilder, builder => builder.AddHandler(handlerMock.Object), async (client, token) => { diff --git a/src/Bicep.Local.Deploy/Extensibility/GrpcBuiltInLocalExtension.cs b/src/Bicep.Local.Deploy/Extensibility/GrpcBuiltInLocalExtension.cs index 13e52c5672a..57ea1b6d3d8 100644 --- a/src/Bicep.Local.Deploy/Extensibility/GrpcBuiltInLocalExtension.cs +++ b/src/Bicep.Local.Deploy/Extensibility/GrpcBuiltInLocalExtension.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Net.Sockets; using System.Text.Json.Nodes; using Azure.Deployments.Extensibility.Core.V2.Json; using Bicep.Local.Extension.Rpc; using Google.Protobuf.Collections; +using Grpc.Net.Client; using Json.Pointer; using Microsoft.WindowsAzure.ResourceStack.Common.Json; using Newtonsoft.Json.Linq; @@ -27,12 +29,28 @@ private GrpcBuiltInLocalExtension(BicepExtension.BicepExtensionClient client, Pr public static async Task Start(Uri pathToBinary) { - var socketName = $"{Guid.NewGuid()}.tmp"; - var socketPath = Path.Combine(Path.GetTempPath(), socketName); + string processArgs; + Func channelBuilder; - if (File.Exists(socketPath)) + if (Socket.OSSupportsUnixDomainSockets) { - File.Delete(socketPath); + var socketName = $"{Guid.NewGuid()}.tmp"; + var socketPath = Path.Combine(Path.GetTempPath(), socketName); + + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + + processArgs = $"--socket {socketPath}"; + channelBuilder = () => GrpcChannelHelper.CreateDomainSocketChannel(socketPath); + } + else + { + var pipeName = $"{Guid.NewGuid()}.tmp"; + + processArgs = $"--pipe {pipeName}"; + channelBuilder = () => GrpcChannelHelper.CreateNamedPipeChannel(pipeName); } var process = new Process @@ -40,7 +58,7 @@ public static async Task Start(Uri pathToBinary) StartInfo = new ProcessStartInfo { FileName = pathToBinary.LocalPath, - Arguments = $"--socket {socketPath}", + Arguments = processArgs, UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true, @@ -62,8 +80,7 @@ public static async Task Start(Uri pathToBinary) process.BeginErrorReadLine(); process.BeginOutputReadLine(); - var channel = GrpcChannelHelper.CreateChannel(socketPath); - var client = new BicepExtension.BicepExtensionClient(channel); + var client = new BicepExtension.BicepExtensionClient(channelBuilder()); await GrpcChannelHelper.WaitForConnectionAsync(client, cts.Token); diff --git a/src/Bicep.Local.Extension.Mock/Program.cs b/src/Bicep.Local.Extension.Mock/Program.cs index 963846b2857..9797ec47a39 100644 --- a/src/Bicep.Local.Extension.Mock/Program.cs +++ b/src/Bicep.Local.Extension.Mock/Program.cs @@ -34,15 +34,22 @@ public static void RegisterHandlers(ResourceDispatcherBuilder builder) => builde public class KestrelProviderExtension : ProviderExtension { - protected override async Task RunServer(string socketPath, ResourceDispatcher dispatcher, CancellationToken cancellationToken) + protected override async Task RunServer(ConnectionOptions connectionOptions, ResourceDispatcher dispatcher, CancellationToken cancellationToken) { var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(options => { - options.ListenUnixSocket(socketPath, listenOptions => + switch (connectionOptions) { - listenOptions.Protocols = HttpProtocols.Http2; - }); + case { Socket: {}, Pipe: null }: + options.ListenUnixSocket(connectionOptions.Socket, listenOptions => listenOptions.Protocols = HttpProtocols.Http2); + break; + case { Socket: null, Pipe: {} }: + options.ListenNamedPipe(connectionOptions.Pipe, listenOptions => listenOptions.Protocols = HttpProtocols.Http2); + break; + default: + throw new InvalidOperationException("Either socketPath or pipeName must be specified."); + } }); builder.Services.AddGrpc(); diff --git a/src/Bicep.Local.Extension/ProviderExtension.cs b/src/Bicep.Local.Extension/ProviderExtension.cs index 7d5d4f9b7b0..10c56af6c80 100644 --- a/src/Bicep.Local.Extension/ProviderExtension.cs +++ b/src/Bicep.Local.Extension/ProviderExtension.cs @@ -9,11 +9,18 @@ namespace Bicep.Local.Extension; public abstract class ProviderExtension { + public record ConnectionOptions( + string? Socket, + string? Pipe); + internal class CommandLineOptions { [Option("socket", Required = false, HelpText = "The path to the domain socket to connect on")] public string? Socket { get; set; } + [Option("pipe", Required = false, HelpText = "The named pipe to connect on")] + public string? Pipe { get; set; } + [Option("wait-for-debugger", Required = false, HelpText = "If set, wait for a dotnet debugger to be attached before starting the server")] public bool WaitForDebugger { get; set; } } @@ -41,7 +48,7 @@ await parser.ParseArguments(args) .WithParsedAsync(async options => await RunServer(registerHandlers, options, cancellationToken)); } - protected abstract Task RunServer(string socketPath, ResourceDispatcher dispatcher, CancellationToken cancellationToken); + protected abstract Task RunServer(ConnectionOptions options, ResourceDispatcher dispatcher, CancellationToken cancellationToken); private async Task RunServer(Action registerHandlers, CommandLineOptions options, CancellationToken cancellationToken) { @@ -64,13 +71,8 @@ private async Task RunServer(Action registerHandlers, registerHandlers(handlerBuilder); var dispatcher = handlerBuilder.Build(); - if (options.Socket is { } socketPath) - { - await Task.WhenAny(RunServer(socketPath, dispatcher, cancellationToken), WaitForCancellation(cancellationToken)); - return; - } - - throw new NotImplementedException(); + ConnectionOptions connectionOptions = new(options.Socket, options.Pipe); + await Task.WhenAny(RunServer(connectionOptions, dispatcher, cancellationToken), WaitForCancellation(cancellationToken)); } private static async Task WaitForCancellation(CancellationToken cancellationToken) diff --git a/src/Bicep.Local.Extension/Rpc/GrpcChannelHelper.cs b/src/Bicep.Local.Extension/Rpc/GrpcChannelHelper.cs index 0a43cbc8963..09c375d1d8a 100644 --- a/src/Bicep.Local.Extension/Rpc/GrpcChannelHelper.cs +++ b/src/Bicep.Local.Extension/Rpc/GrpcChannelHelper.cs @@ -2,8 +2,12 @@ // Licensed under the MIT License. using System.Collections.Immutable; +using System.IO.Pipes; using System.Net; using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Security.AccessControl; +using System.Security.Principal; using Grpc.Core; using Grpc.Net.Client; @@ -38,7 +42,7 @@ public async ValueTask ConnectAsync(SocketsHttpConnectionContext _, } } - public static GrpcChannel CreateChannel(string socketPath) + public static GrpcChannel CreateDomainSocketChannel(string socketPath) { var udsEndPoint = new UnixDomainSocketEndPoint(socketPath); var connectionFactory = new UnixDomainSocketsConnectionFactory(udsEndPoint); @@ -54,6 +58,40 @@ public static GrpcChannel CreateChannel(string socketPath) }); } + public static GrpcChannel CreateNamedPipeChannel(string pipeName) + { + static async ValueTask connectPipe(string pipeName, CancellationToken cancellationToken) + { + var clientStream = new NamedPipeClientStream( + serverName: ".", + pipeName: pipeName, + direction: PipeDirection.InOut, + options: PipeOptions.WriteThrough | PipeOptions.Asynchronous, + impersonationLevel: TokenImpersonationLevel.Anonymous); + + try + { + await clientStream.ConnectAsync(cancellationToken).ConfigureAwait(false); + return clientStream; + } + catch + { + await clientStream.DisposeAsync(); + throw; + } + } + + var socketsHttpHandler = new SocketsHttpHandler + { + ConnectCallback = (context, cancellationToken) => connectPipe(pipeName, cancellationToken), + }; + + return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = socketsHttpHandler + }); + } + public static async Task WaitForConnectionAsync(BicepExtension.BicepExtensionClient client, CancellationToken cancellationToken) { var connected = false;