Build dotnet-monitor tool as ASP.NET Core 3.1 application. (#1420)
authorJustin Anderson <jander-msft@users.noreply.github.com>
Tue, 18 Aug 2020 23:40:42 +0000 (16:40 -0700)
committerGitHub <noreply@github.com>
Tue, 18 Aug 2020 23:40:42 +0000 (16:40 -0700)
* Filter dotnet-monitor from available processes.
* Change port description to be supplied by options configuration.
* Enable synchronous IO on response output streams.
* Use Brotli compression instead of GZip.

14 files changed:
src/Microsoft.Diagnostics.Monitoring.RestServer/Microsoft.Diagnostics.Monitoring.RestServer.csproj
src/Microsoft.Diagnostics.Monitoring.RestServer/Models/EventPipeProviderModel.cs
src/Microsoft.Diagnostics.Monitoring/RuntimeInfo.cs
src/Microsoft.Diagnostics.Monitoring/ServiceCollectionExtensions.cs [deleted file]
src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsClient/DiagnosticsClient.cs
src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/ProcessInfo.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj
src/Tools/dotnet-monitor/DiagnosticPortConfiguration.cs [new file with mode: 0644]
src/Tools/dotnet-monitor/DiagnosticsMonitorCommandHandler.cs
src/Tools/dotnet-monitor/FilteredEndpointInfoSource.cs [new file with mode: 0644]
src/Tools/dotnet-monitor/FilteredEndpointInfoSourceHostedService.cs [new file with mode: 0644]
src/Tools/dotnet-monitor/Startup.cs
src/Tools/dotnet-monitor/dotnet-monitor.csproj
src/Tools/dotnet-monitor/runtimeconfig.template.json [deleted file]

index 2d5b47fdf4e51237cf6d8213f111332ea54f49bc..757e7331c7904485e575579c05b854770e542bc8 100644 (file)
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFrameworks>netstandard2.0;netcoreapp3.1</TargetFrameworks>
     <NoWarn>;1591;1701</NoWarn>
     <Description>REST Api surface for dotnet-monitor</Description>
     <!-- Tentatively create package so other teams can tenatively consume. -->
@@ -15,7 +15,7 @@
     <OutputType>Library</OutputType>
   </PropertyGroup>
 
-  <ItemGroup>
+  <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
     <PackageReference Include="Microsoft.AspNetCore" Version="$(MicrosoftAspNetCoreVersion)" />
     <PackageReference Include="Microsoft.AspNetCore.HttpsPolicy" Version="$(MicrosoftAspNetCoreHttpsPolicyVersion)" />
     <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="$(MicrosoftAspNetCoreMvcVersion)" />
index ce44f1dd6e058ee0fd0115d9b83f1e20988f5c44..71a0e959eddf5193c7d1f950e53a10e1854843c1 100644 (file)
@@ -6,7 +6,6 @@ using System.Collections.Generic;
 using System.Diagnostics.Tracing;
 using System.Runtime.Serialization;
 using Microsoft.Diagnostics.Monitoring.RestServer.Validation;
-using Newtonsoft.Json;
 
 namespace Microsoft.Diagnostics.Monitoring.RestServer.Models
 {
index 5c7af95c7aa8908751faacb0ed9bdc1b0391172e..e89a7aeeb9d4651550f2e503a9f083d958b15e51 100644 (file)
@@ -1,10 +1,20 @@
-using System.IO;
+using System;
+using System.IO;
 using System.Runtime.InteropServices;
 
 namespace Microsoft.Diagnostics.Monitoring
 {
     public static class RuntimeInfo
     {
+        public static bool IsDiagnosticsEnabled
+        {
+            get
+            {
+                string enableDiagnostics = Environment.GetEnvironmentVariable("COMPlus_EnableDiagnostics");
+                return string.IsNullOrEmpty(enableDiagnostics) || !"0".Equals(enableDiagnostics, StringComparison.Ordinal);
+            }
+        }
+
         public static bool IsInDockerContainer
         {
             get
diff --git a/src/Microsoft.Diagnostics.Monitoring/ServiceCollectionExtensions.cs b/src/Microsoft.Diagnostics.Monitoring/ServiceCollectionExtensions.cs
deleted file mode 100644 (file)
index c0f2640..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-// See the LICENSE file in the project root for more information.
-
-using Microsoft.Diagnostics.NETCore.Client;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.Diagnostics.Monitoring
-{
-    public static class ServiceCollectionExtensions
-    {
-        public static IServiceCollection AddEndpointInfoSource(this IServiceCollection services, string reversedServerAddress, int? maxConnections = null)
-        {
-            if (string.IsNullOrWhiteSpace(reversedServerAddress))
-            {
-                return services.AddSingleton<IEndpointInfoSource, ClientEndpointInfoSource>();
-            }
-            else
-            {
-                // Construct the source now rather than delayed construction
-                // in order to be able to accept diagnostics connections immediately.
-                var serverSource = new ServerEndpointInfoSource(reversedServerAddress);
-                serverSource.Start(maxConnections.GetValueOrDefault(ReversedDiagnosticsServer.MaxAllowedConnections));
-
-                return services.AddSingleton<IEndpointInfoSource>(serverSource);
-            }
-        }
-    }
-}
index 89a4d4240bbf1a5851a9367a117613d513d57e52..06f9a0fac9ec53397fa66c2f6b64c9e3ff0b04a0 100644 (file)
@@ -174,6 +174,22 @@ namespace Microsoft.Diagnostics.NETCore.Client
             }
         }
 
+        internal ProcessInfo GetProcessInfo()
+        {
+            IpcMessage message = new IpcMessage(DiagnosticsServerCommandSet.Process, (byte)ProcessCommandId.GetProcessInfo);
+            var response = IpcClient.SendMessage(_endpoint, message);
+            switch ((DiagnosticsServerResponseId)response.Header.CommandId)
+            {
+                case DiagnosticsServerResponseId.Error:
+                    var hr = BitConverter.ToInt32(response.Payload, 0);
+                    throw new ServerErrorException($"Get process info failed (HRESULT: 0x{hr:X8})");
+                case DiagnosticsServerResponseId.OK:
+                    return ProcessInfo.Parse(response.Payload);
+                default:
+                    throw new ServerErrorException($"Get process info failed - server responded with unknown command");
+            }
+        }
+
         /// <summary>
         /// Get all the active processes that can be attached to.
         /// </summary>
diff --git a/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/ProcessInfo.cs b/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/ProcessInfo.cs
new file mode 100644 (file)
index 0000000..91bec0c
--- /dev/null
@@ -0,0 +1,69 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Text;
+
+namespace Microsoft.Diagnostics.NETCore.Client
+{
+    /**
+     * ==ProcessInfo==
+     * The response payload to issuing the GetProcessInfo command.
+     * 
+     * 8 bytes  - PID (little-endian)
+     * 16 bytes - CLR Runtime Instance Cookie (little-endian)
+     * # bytes  - Command line string length and data
+     * # bytes  - Operating system string length and data
+     * # bytes  - Process architecture string length and data
+     * 
+     * The "string length and data" fields are variable length:
+     * 4 bytes            - Length of string data in UTF-16 characters
+     * (2 * length) bytes - The data of the string encoded using Unicode
+     *                      (includes null terminating character)
+     */
+
+    internal class ProcessInfo
+    {
+        private static readonly int GuidSizeInBytes = 16;
+
+        public static ProcessInfo Parse(byte[] payload)
+        {
+            ProcessInfo processInfo = new ProcessInfo();
+
+            int index = 0;
+            processInfo.ProcessId = BitConverter.ToUInt64(payload, index);
+            index += sizeof(UInt64);
+
+            byte[] cookieBuffer = new byte[GuidSizeInBytes];
+            Array.Copy(payload, index, cookieBuffer, 0, GuidSizeInBytes);
+            processInfo.RuntimeInstanceCookie = new Guid(cookieBuffer);
+            index += GuidSizeInBytes;
+
+            processInfo.CommandLine = ReadString(payload, ref index);
+            processInfo.OperatingSystem = ReadString(payload, ref index);
+            processInfo.ProcessArchitecture = ReadString(payload, ref index);
+
+            return processInfo;
+        }
+
+        private static string ReadString(byte[] buffer, ref int index)
+        {
+            // Length of the string of UTF-16 characters
+            int length = (int)BitConverter.ToUInt32(buffer, index);
+            index += sizeof(UInt32);
+
+            int size = (int)length * sizeof(char);
+            // The string contains an ending null character; remove it before returning the value
+            string value = Encoding.Unicode.GetString(buffer, index, size).Substring(0, length - 1);
+            index += size;
+            return value;
+        }
+
+        public UInt64 ProcessId { get; private set; }
+        public Guid RuntimeInstanceCookie { get; private set; }
+        public string CommandLine { get; private set; }
+        public string OperatingSystem { get; private set; }
+        public string ProcessArchitecture { get; private set; }
+    }
+}
\ No newline at end of file
index 88d4a9c22f465df611bf6562b57a2d4c0fd9b7a0..b15b3b957f62044c775cd2a8ab50a9c0ed97216b 100644 (file)
@@ -18,6 +18,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <InternalsVisibleTo Include="dotnet-monitor" />
     <InternalsVisibleTo Include="DotnetMonitor.UnitTests" />
     <InternalsVisibleTo Include="Microsoft.Diagnostics.Monitoring" />
     <InternalsVisibleTo Include="Microsoft.Diagnostics.NETCore.Client.UnitTests" />
diff --git a/src/Tools/dotnet-monitor/DiagnosticPortConfiguration.cs b/src/Tools/dotnet-monitor/DiagnosticPortConfiguration.cs
new file mode 100644 (file)
index 0000000..4ed5434
--- /dev/null
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.Diagnostics.Tools.Monitor
+{
+    public class DiagnosticPortConfiguration
+    {
+        public DiagnosticPortConnectionMode ConnectionMode { get; set; }
+
+        public string EndpointName { get; set; }
+
+        public int? MaxConnections { get; set; }
+    }
+
+    public enum DiagnosticPortConnectionMode
+    {
+        Connect,
+        Listen
+    }
+}
index d2a90e896becae0492ae7d2ab5cdf73c11467a6e..6aa568d90abdc6d256203aa0fd58342faf3deb82 100644 (file)
@@ -40,6 +40,7 @@ namespace Microsoft.Diagnostics.Tools.Monitor
             IWebHostBuilder builder = WebHost.CreateDefaultBuilder()
                 .ConfigureAppConfiguration((IConfigurationBuilder builder) =>
                 {
+                    ConfigureEndpointInfoSource(builder, reversedServerAddress);
                     if (metrics)
                     {
                         //Note these are in precedence order.
@@ -50,8 +51,10 @@ namespace Microsoft.Diagnostics.Tools.Monitor
                 })
                 .ConfigureServices((WebHostBuilderContext context, IServiceCollection services) =>
                 {
-                    services.AddEndpointInfoSource(reversedServerAddress);
                     //TODO Many of these service additions should be done through extension methods
+                    services.Configure<DiagnosticPortConfiguration>(context.Configuration.GetSection(nameof(DiagnosticPortConfiguration)));
+                    services.AddSingleton<IEndpointInfoSource, FilteredEndpointInfoSource>();
+                    services.AddHostedService<FilteredEndpointInfoSourceHostedService>();
                     services.AddSingleton<IDiagnosticServices, DiagnosticServices>();
                     if (metrics)
                     {
@@ -75,6 +78,16 @@ namespace Microsoft.Diagnostics.Tools.Monitor
             });
         }
 
+        private static void ConfigureEndpointInfoSource(IConfigurationBuilder builder, string diagnosticPort)
+        {
+            DiagnosticPortConnectionMode connectionMode = string.IsNullOrEmpty(diagnosticPort) ? DiagnosticPortConnectionMode.Connect : DiagnosticPortConnectionMode.Listen;
+            builder.AddInMemoryCollection(new Dictionary<string, string>
+            {
+                {MakeKey(nameof(DiagnosticPortConfiguration), nameof(DiagnosticPortConfiguration.ConnectionMode)), connectionMode.ToString()},
+                {MakeKey(nameof(DiagnosticPortConfiguration), nameof(DiagnosticPortConfiguration.EndpointName)), diagnosticPort}
+            });
+        }
+
         private static string MakeKey(string parent, string child)
         {
             return FormattableString.Invariant($"{parent}:{child}");
diff --git a/src/Tools/dotnet-monitor/FilteredEndpointInfoSource.cs b/src/Tools/dotnet-monitor/FilteredEndpointInfoSource.cs
new file mode 100644 (file)
index 0000000..a22754a
--- /dev/null
@@ -0,0 +1,110 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Diagnostics.Monitoring;
+using Microsoft.Diagnostics.NETCore.Client;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Diagnostics.Tools.Monitor
+{
+    /// <summary>
+    /// Wraps an <see cref="IEndpointInfoSource"/> based on the provided configuration
+    /// and filters the current process from consideration.
+    /// </summary>
+    internal class FilteredEndpointInfoSource : IEndpointInfoSourceInternal, IAsyncDisposable
+    {
+        private readonly DiagnosticPortConfiguration _configuration;
+        private readonly int? _processIdToFilterOut;
+        private readonly Guid? _runtimeInstanceCookieToFilterOut;
+        private readonly IEndpointInfoSourceInternal _source;
+
+        public FilteredEndpointInfoSource(IOptions<DiagnosticPortConfiguration> configuration)
+        {
+            _configuration = configuration.Value;
+            switch (_configuration.ConnectionMode)
+            {
+                case DiagnosticPortConnectionMode.Connect:
+                    _source = new ClientEndpointInfoSource();
+                    break;
+                case DiagnosticPortConnectionMode.Listen:
+                    _source = new ServerEndpointInfoSource(_configuration.EndpointName);
+                    break;
+                default:
+                    throw new InvalidOperationException($"Unhandled connection mode: {_configuration.ConnectionMode}");
+            }
+
+            // Filter out the current process based on the connection mode.
+            if (RuntimeInfo.IsDiagnosticsEnabled)
+            {
+                int pid = Process.GetCurrentProcess().Id;
+
+                // Regardless of connection mode, can use the runtime instance cookie to filter self out.
+                try
+                {
+                    var client = new DiagnosticsClient(pid);
+                    Guid runtimeInstanceCookie = client.GetProcessInfo().RuntimeInstanceCookie;
+                    if (Guid.Empty != runtimeInstanceCookie)
+                    {
+                        _runtimeInstanceCookieToFilterOut = runtimeInstanceCookie;
+                    }
+                }
+                catch (Exception)
+                {
+                }
+
+                // If connecting to runtime instances, filter self out. In listening mode, it's likely
+                // that multiple processes have the same PID in multi-container scenarios.
+                if (DiagnosticPortConnectionMode.Connect == configuration.Value.ConnectionMode)
+                {
+                    _processIdToFilterOut = pid;
+                }
+            }
+        }
+
+        public async Task<IEnumerable<IEndpointInfo>> GetEndpointInfoAsync(CancellationToken token)
+        {
+            var endpointInfos = await _source.GetEndpointInfoAsync(token);
+
+            // Apply process ID filter
+            if (_processIdToFilterOut.HasValue)
+            {
+                endpointInfos = endpointInfos.Where(info => info.ProcessId != _processIdToFilterOut.Value);
+            }
+
+            // Apply runtime instance cookie filter
+            if (_runtimeInstanceCookieToFilterOut.HasValue)
+            {
+                endpointInfos = endpointInfos.Where(info => info.RuntimeInstanceCookie != _runtimeInstanceCookieToFilterOut.Value);
+            }
+
+            return endpointInfos;
+        }
+
+        public async ValueTask DisposeAsync()
+        {
+            if (_source is IDisposable disposable)
+            {
+                disposable.Dispose();
+            }
+            else if (_source is IAsyncDisposable asyncDisposable)
+            {
+                await asyncDisposable.ConfigureAwait(false).DisposeAsync();
+            }
+        }
+
+        public void Start()
+        {
+            if (_source is ServerEndpointInfoSource source)
+            {
+                source.Start(_configuration.MaxConnections.GetValueOrDefault(ReversedDiagnosticsServer.MaxAllowedConnections));
+            }
+        }
+    }
+}
diff --git a/src/Tools/dotnet-monitor/FilteredEndpointInfoSourceHostedService.cs b/src/Tools/dotnet-monitor/FilteredEndpointInfoSourceHostedService.cs
new file mode 100644 (file)
index 0000000..d72de2b
--- /dev/null
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Diagnostics.Monitoring;
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Diagnostics.Tools.Monitor
+{
+    /// <summary>
+    /// A hosted service that ensures the <see cref="FilteredEndpointInfoSource"/>
+    /// starts monitoring for connectable processes.
+    /// </summary>
+    internal class FilteredEndpointInfoSourceHostedService : IHostedService
+    {
+        private readonly FilteredEndpointInfoSource _source;
+
+        public FilteredEndpointInfoSourceHostedService(IEndpointInfoSource source)
+        {
+            _source = (FilteredEndpointInfoSource)source;
+        }
+
+        public Task StartAsync(CancellationToken cancellationToken)
+        {
+            _source.Start();
+
+            return Task.CompletedTask;
+        }
+
+        public Task StopAsync(CancellationToken cancellationToken)
+        {
+            return Task.CompletedTask;
+        }
+    }
+}
index 7d910455086cb0345e20463200ceadc70b854357..50222bf9db4ce66af8183d8ab21fd7e5bfaaa309 100644 (file)
@@ -2,19 +2,18 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 // See the LICENSE file in the project root for more information.
 
-using System;
 using System.Collections.Generic;
 using System.IO.Compression;
-using System.Linq;
-using System.Threading.Tasks;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.ResponseCompression;
-using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
 using Microsoft.Diagnostics.Monitoring.RestServer;
+using Microsoft.Diagnostics.Monitoring.RestServer.Controllers;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
 
 namespace Microsoft.Diagnostics.Monitoring
 {
@@ -30,14 +29,14 @@ namespace Microsoft.Diagnostics.Monitoring
         // This method gets called by the runtime. Use this method to add services to the container.
         public void ConfigureServices(IServiceCollection services)
         {
-            services.AddMvc((MvcOptions options) =>
-            {
-                options.Filters.Add(new ProducesAttribute("application/json"));
+            services.AddMvc(options =>
+                {
+                    options.Filters.Add(new ProducesAttribute("application/json"));
 
-                // HACK We need to disable EndpointRouting in order to run properly in 3.1
-                System.Reflection.PropertyInfo prop = options.GetType().GetProperty("EnableEndpointRouting");
-                prop?.SetValue(options, false);
-            }).SetCompatibilityVersion(CompatibilityVersion.Latest);
+                    options.EnableEndpointRouting = false;
+                })
+                .SetCompatibilityVersion(CompatibilityVersion.Latest)
+                .AddApplicationPart(typeof(DiagController).Assembly);
 
             services.Configure<ApiBehaviorOptions>(options =>
             {
@@ -50,20 +49,27 @@ namespace Microsoft.Diagnostics.Monitoring
                 };
             });
 
-            services.Configure<GzipCompressionProviderOptions>(options =>
+            services.Configure<BrotliCompressionProviderOptions>(options =>
             {
                 options.Level = CompressionLevel.Optimal;
             });
 
             services.AddResponseCompression(configureOptions =>
             {
-                configureOptions.Providers.Add<GzipCompressionProvider>();
+                configureOptions.Providers.Add<BrotliCompressionProvider>();
                 configureOptions.MimeTypes = new List<string> { "application/octet-stream" };
             });
 
-            var config = new PrometheusConfiguration();
-            Configuration.Bind(nameof(PrometheusConfiguration), config);
-            if (config.Enabled)
+            // This is needed to allow the StreamingLogger to synchronously write to the output stream.
+            // Eventually should switch StreamingLoggger to something that allows for async operations.
+            services.Configure<KestrelServerOptions>(options =>
+            {
+                options.AllowSynchronousIO = true;
+            });
+
+            var prometheusConfig = new PrometheusConfiguration();
+            Configuration.Bind(nameof(PrometheusConfiguration), prometheusConfig);
+            if (prometheusConfig.Enabled)
             {
                 services.AddSingleton<MetricsStoreService>();
                 services.AddHostedService<MetricsService>();
@@ -71,7 +77,7 @@ namespace Microsoft.Diagnostics.Monitoring
         }
 
         // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
-        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
+        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
         {
             if (env.IsDevelopment())
             {
index ddeee4220fb8a6839fbec3e54fafda75e7a119bf..e76cc6674e506e011b3f0c256090b4900434cf1b 100644 (file)
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFramework>netcoreapp2.1</TargetFramework>
+    <TargetFramework>netcoreapp3.1</TargetFramework>
     <RuntimeIdentifiers>linux-x64;linux-musl-x64;win-x64</RuntimeIdentifiers>
     <PackAsToolShimRuntimeIdentifiers>linux-x64;linux-musl-x64;win-x64</PackAsToolShimRuntimeIdentifiers>
     <RootNamespace>Microsoft.Diagnostics.Tools.Monitor</RootNamespace>
@@ -10,6 +10,7 @@
     <PackageTags>Diagnostic</PackageTags>
     <IsShipping>false</IsShipping>
     <PackageReleaseNotes>$(Description)</PackageReleaseNotes>
+    <RollForward>Major</RollForward>
     <!-- This forces the creation of a checksum file and uploads it to blob storage
          using this name as part of the blob relative path. -->
     <BlobGroupPrefix>monitor</BlobGroupPrefix>
diff --git a/src/Tools/dotnet-monitor/runtimeconfig.template.json b/src/Tools/dotnet-monitor/runtimeconfig.template.json
deleted file mode 100644 (file)
index f022b7f..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "rollForwardOnNoCandidateFx": 2
-}
\ No newline at end of file