diff --git a/build/dependencies.props b/build/dependencies.props index a30da30215..ce5a629445 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -33,6 +33,7 @@ 2.2.0 2.2.0 2.2.0 + 2.2.0 2.2.0 2.2.0 2.2.0 diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 286c298e36..200354b017 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -14,6 +14,7 @@ + diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index aec24922ce..92c4598018 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -45,6 +45,8 @@ + + diff --git a/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj b/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj index a469dc113a..50970c8d86 100644 --- a/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj +++ b/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj @@ -12,6 +12,7 @@ aspnetcore;testing false true + true false diff --git a/src/Middleware/Diagnostics/test/FunctionalTests/Diagnostics.FunctionalTests.csproj b/src/Middleware/Diagnostics/test/FunctionalTests/Diagnostics.FunctionalTests.csproj index 13a082db5e..f581883ab8 100644 --- a/src/Middleware/Diagnostics/test/FunctionalTests/Diagnostics.FunctionalTests.csproj +++ b/src/Middleware/Diagnostics/test/FunctionalTests/Diagnostics.FunctionalTests.csproj @@ -3,8 +3,6 @@ $(StandardTestTfms) false - false - Diagnostics.FunctionalTests diff --git a/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj b/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj index 3623c34990..5c37cdf8c4 100644 --- a/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj +++ b/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj @@ -8,11 +8,11 @@ - + - + diff --git a/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/DatabaseErrorPageSample.csproj b/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/DatabaseErrorPageSample.csproj index 9f7630aedb..cdd38cc36e 100644 --- a/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/DatabaseErrorPageSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/DatabaseErrorPageSample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0;netcoreapp2.1 + net461;netcoreapp2.2 diff --git a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj index 1bc19e49c5..2c4812c068 100644 --- a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0;netcoreapp2.1 + net461;netcoreapp2.2 diff --git a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj index 7710795716..56d768b5f9 100644 --- a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0;netcoreapp2.1 + net461;netcoreapp2.2 diff --git a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj index 40481fde37..84841ec052 100644 --- a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0;netcoreapp2.1 + net461;netcoreapp2.2 diff --git a/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/WelcomePageSample.csproj b/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/WelcomePageSample.csproj index 40481fde37..84841ec052 100644 --- a/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/WelcomePageSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/WelcomePageSample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0;netcoreapp2.1 + net461;netcoreapp2.2 diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/src/DbContextHealthCheck.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/src/DbContextHealthCheck.cs new file mode 100644 index 0000000000..7fa998f296 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/src/DbContextHealthCheck.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal sealed class DbContextHealthCheck : IHealthCheck where TContext : DbContext + { + private static readonly Func> DefaultTestQuery = (dbContext, cancellationToken) => + { + return dbContext.Database.CanConnectAsync(cancellationToken); + }; + + private readonly TContext _dbContext; + private readonly IOptionsMonitor> _options; + + public DbContextHealthCheck(TContext dbContext, IOptionsMonitor> options) + { + if (dbContext == null) + { + throw new ArgumentNullException(nameof(dbContext)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _dbContext = dbContext; + _options = options; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var options = _options.Get(context.Registration.Name); + var testQuery = options.CustomTestQuery ?? DefaultTestQuery; + + if (await testQuery(_dbContext, cancellationToken)) + { + return HealthCheckResult.Healthy(); + } + + return HealthCheckResult.Unhealthy(); + } + } +} diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/src/DbContextHealthCheckOptions.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/src/DbContextHealthCheckOptions.cs new file mode 100644 index 0000000000..7fb330c376 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/src/DbContextHealthCheckOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal sealed class DbContextHealthCheckOptions where TContext : DbContext + { + public Func> CustomTestQuery { get; set; } + } +} diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/src/DependencyInjection/EntityFrameworkCoreHealthChecksBuilderExtensions.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/src/DependencyInjection/EntityFrameworkCoreHealthChecksBuilderExtensions.cs new file mode 100644 index 0000000000..bf299bc6b0 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/src/DependencyInjection/EntityFrameworkCoreHealthChecksBuilderExtensions.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class EntityFrameworkCoreHealthChecksBuilderExtensions + { + /// + /// Adds a health check for the specified type. + /// + /// The type. + /// The . + /// + /// The health check name. Optional. If null the type name of will be used for the name. + /// + /// + /// The that should be reported when the health check fails. Optional. If null then + /// the default status of will be reported. + /// + /// A list of tags that can be used to filter sets of health checks. Optional. + /// + /// A custom test query that will be executed when the health check executes to test the health of the database + /// connection and configurations. + /// + /// The . + /// + /// + /// The health check implementation added by this method will use the dependency injection container + /// to create an instance of . + /// + /// + /// By default the health check implementation will use the method + /// to test connectivity to the database. This method requires that the database provider has correctly implemented the + /// interface. If the database provide has not implemented this interface + /// then the health check will report a failure. + /// + /// + /// Providing a will replace the use of + /// to test database connectivity. An implementation of a test query should handle exceptions that can arise due to connectivity failure, + /// and should return a pass/fail result. The test query should be be designed to complete in a short and predicatable amount of time. + /// + /// + public static IHealthChecksBuilder AddDbContextCheck( + this IHealthChecksBuilder builder, + string name = null, + HealthStatus? failureStatus = default, + IEnumerable tags = default, + Func> customTestQuery = default) + where TContext : DbContext + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + name = typeof(TContext).Name; + } + + if (customTestQuery != null) + { + builder.Services.Configure>(name, options => options.CustomTestQuery = customTestQuery); + } + + return builder.AddCheck>(name, failureStatus, tags); + } + } +} diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/src/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.csproj b/src/Middleware/HealthChecks.EntityFrameworkCore/src/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.csproj new file mode 100644 index 0000000000..6b59175d82 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/src/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.csproj @@ -0,0 +1,20 @@ + + + + + Components for performing health checks using EntityFrameworkCore. + + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks;entityframeworkcore + Microsoft.Extensions.Diagnostics.HealthChecks + + + + + + + + + diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/src/Properties/AssemblyInfo.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1aa256d83a --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/src/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ No newline at end of file diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/test/DbContextHealthCheckTest.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/test/DbContextHealthCheckTest.cs new file mode 100644 index 0000000000..b564a62795 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/test/DbContextHealthCheckTest.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class DbContextHealthCheckTest + { + // Just testing healthy here since it would be complicated to simulate a failure. All of that logic lives in EF anyway. + [Fact] + public async Task CheckAsync_DefaultTest_Healthy() + { + // Arrange + var services = CreateServices(); + using (var scope = services.GetRequiredService().CreateScope()) + { + var registration = Assert.Single(services.GetRequiredService>().Value.Registrations); + var check = ActivatorUtilities.CreateInstance>(scope.ServiceProvider); + + // Act + var result = await check.CheckHealthAsync(new HealthCheckContext() { Registration = registration, }); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + } + } + + [Fact] + public async Task CheckAsync_CustomTest_Healthy() + { + // Arrange + var services = CreateServices(async (c, ct) => + { + return 0 < await c.Blogs.CountAsync(); + }); + + using (var scope = services.GetRequiredService().CreateScope()) + { + var registration = Assert.Single(services.GetRequiredService>().Value.Registrations); + var check = ActivatorUtilities.CreateInstance>(scope.ServiceProvider); + + // Add a blog so that the custom test passes + var context = scope.ServiceProvider.GetRequiredService(); + context.Add(new Blog()); + await context.SaveChangesAsync(); + + // Act + var result = await check.CheckHealthAsync(new HealthCheckContext() { Registration = registration, }); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + } + } + + [Fact] + public async Task CheckAsync_CustomTest_Degraded() + { + // Arrange + var services = CreateServices(async (c, ct) => + { + return 0 < await c.Blogs.CountAsync(); + }, failureStatus: HealthStatus.Degraded); + + using (var scope = services.GetRequiredService().CreateScope()) + { + var registration = Assert.Single(services.GetRequiredService>().Value.Registrations); + var check = ActivatorUtilities.CreateInstance>(scope.ServiceProvider); + + // Act + var result = await check.CheckHealthAsync(new HealthCheckContext() { Registration = registration, }); + + // Assert + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + } + + [Fact] + public async Task CheckAsync_CustomTest_Unhealthy() + { + // Arrange + var services = CreateServices(async (c, ct) => + { + return 0 < await c.Blogs.CountAsync(); + }, failureStatus: HealthStatus.Unhealthy); + + using (var scope = services.GetRequiredService().CreateScope()) + { + var registration = Assert.Single(services.GetRequiredService>().Value.Registrations); + var check = ActivatorUtilities.CreateInstance>(scope.ServiceProvider); + + // Act + var result = await check.CheckHealthAsync(new HealthCheckContext() { Registration = registration, }); + + // Assert + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + } + + private static IServiceProvider CreateServices( + Func> testQuery = null, + HealthStatus failureStatus = HealthStatus.Unhealthy) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddDbContext(o => o.UseInMemoryDatabase("Test")); + + var builder = serviceCollection.AddHealthChecks(); + builder.AddDbContextCheck("test", failureStatus, new[] { "tag1", "tag2", }, testQuery); + return serviceCollection.BuildServiceProvider(); + } + } +} diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/test/DependencyInjection/EntityFrameworkCoreHealthChecksBuilderExtensionsTest.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/test/DependencyInjection/EntityFrameworkCoreHealthChecksBuilderExtensionsTest.cs new file mode 100644 index 0000000000..9efcb025e0 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/test/DependencyInjection/EntityFrameworkCoreHealthChecksBuilderExtensionsTest.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + public class EntityFrameworkCoreHealthChecksBuilderExtensionsTest + { + [Fact] + public void AddDbContextCheck_RegistersDbContextHealthCheck() + { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddDbContext(o => o.UseInMemoryDatabase("Test")); + + var builder = serviceCollection.AddHealthChecks(); + + // Act + builder.AddDbContextCheck("test", HealthStatus.Degraded, new[] { "tag1", "tag2", }, (c, ct) => Task.FromResult(true)); + + // Assert + var services = serviceCollection.BuildServiceProvider(); + + var registrations = services.GetRequiredService>().Value.Registrations; + + var registration = Assert.Single(registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag1", "tag2", }, registration.Tags.ToArray()); + + var options = services.GetRequiredService>>(); + Assert.NotNull(options.Get("test").CustomTestQuery); + + using (var scope = services.GetRequiredService().CreateScope()) + { + var check = Assert.IsType>(registration.Factory(scope.ServiceProvider)); + } + } + } +} diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/test/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.Tests.csproj b/src/Middleware/HealthChecks.EntityFrameworkCore/test/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.Tests.csproj new file mode 100644 index 0000000000..0523f91842 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/test/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,14 @@ + + + + $(StandardTestTfms) + Microsoft.AspNetCore.Diagnostics.HealthChecks + + + + + + + + + diff --git a/src/Middleware/HealthChecks.EntityFrameworkCore/test/TestDbContext.cs b/src/Middleware/HealthChecks.EntityFrameworkCore/test/TestDbContext.cs new file mode 100644 index 0000000000..e38ed93674 --- /dev/null +++ b/src/Middleware/HealthChecks.EntityFrameworkCore/test/TestDbContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.EntityFrameworkCore; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + public class TestDbContext : DbContext + { + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Blogs { get; set; } + } + + public class Blog + { + public int Id { get; set; } + + public int Name { get; set; } + } +} diff --git a/src/Middleware/HealthChecks/src/Builder/HealthCheckApplicationBuilderExtensions.cs b/src/Middleware/HealthChecks/src/Builder/HealthCheckApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..6f54f51765 --- /dev/null +++ b/src/Middleware/HealthChecks/src/Builder/HealthCheckApplicationBuilderExtensions.cs @@ -0,0 +1,254 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// extension methods for the . + /// + public static class HealthCheckApplicationBuilderExtensions + { + /// + /// Adds a middleware that provides health check status. + /// + /// The . + /// The path on which to provide health check status. + /// A reference to the after the operation has completed. + /// + /// + /// If is set to null or the empty string then the health check middleware + /// will ignore the URL path and process all requests. If is set to a non-empty + /// value, the health check middleware will process requests with a URL that matches the provided value + /// of case-insensitively, allowing for an extra trailing slash ('/') character. + /// + /// + /// The health check middleware will use default settings from . + /// + /// + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + UseHealthChecksCore(app, path, port: null, Array.Empty()); + return app; + } + + /// + /// Adds a middleware that provides health check status. + /// + /// The . + /// The path on which to provide health check status. + /// A used to configure the middleware. + /// A reference to the after the operation has completed. + /// + /// + /// If is set to null or the empty string then the health check middleware + /// will ignore the URL path and process all requests. If is set to a non-empty + /// value, the health check middleware will process requests with a URL that matches the provided value + /// of case-insensitively, allowing for an extra trailing slash ('/') character. + /// + /// + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path, HealthCheckOptions options) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + UseHealthChecksCore(app, path, port: null, new[] { Options.Create(options), }); + return app; + } + + /// + /// Adds a middleware that provides health check status. + /// + /// The . + /// The path on which to provide health check status. + /// The port to listen on. Must be a local port on which the server is listening. + /// A reference to the after the operation has completed. + /// + /// + /// If is set to null or the empty string then the health check middleware + /// will ignore the URL path and process all requests on the specified port. If is + /// set to a non-empty value, the health check middleware will process requests with a URL that matches the + /// provided value of case-insensitively, allowing for an extra trailing slash ('/') + /// character. + /// + /// + /// The health check middleware will use default settings from . + /// + /// + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path, int port) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + UseHealthChecksCore(app, path, port, Array.Empty()); + return app; + } + + /// + /// Adds a middleware that provides health check status. + /// + /// The . + /// The path on which to provide health check status. + /// The port to listen on. Must be a local port on which the server is listening. + /// A reference to the after the operation has completed. + /// + /// + /// If is set to null or the empty string then the health check middleware + /// will ignore the URL path and process all requests on the specified port. If is + /// set to a non-empty value, the health check middleware will process requests with a URL that matches the + /// provided value of case-insensitively, allowing for an extra trailing slash ('/') + /// character. + /// + /// + /// The health check middleware will use default settings from . + /// + /// + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path, string port) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (port == null) + { + throw new ArgumentNullException(nameof(port)); + } + + if (!int.TryParse(port, out var portAsInt)) + { + throw new ArgumentException("The port must be a valid integer.", nameof(port)); + } + + UseHealthChecksCore(app, path, portAsInt, Array.Empty()); + return app; + } + + /// + /// Adds a middleware that provides health check status. + /// + /// The . + /// The path on which to provide health check status. + /// The port to listen on. Must be a local port on which the server is listening. + /// A used to configure the middleware. + /// A reference to the after the operation has completed. + /// + /// + /// If is set to null or the empty string then the health check middleware + /// will ignore the URL path and process all requests on the specified port. If is + /// set to a non-empty value, the health check middleware will process requests with a URL that matches the + /// provided value of case-insensitively, allowing for an extra trailing slash ('/') + /// character. + /// + /// + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path, int port, HealthCheckOptions options) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + UseHealthChecksCore(app, path, port, new[] { Options.Create(options), }); + return app; + } + + /// + /// Adds a middleware that provides health check status. + /// + /// The . + /// The path on which to provide health check status. + /// The port to listen on. Must be a local port on which the server is listening. + /// A used to configure the middleware. + /// A reference to the after the operation has completed. + /// + /// + /// If is set to null or the empty string then the health check middleware + /// will ignore the URL path and process all requests on the specified port. If is + /// set to a non-empty value, the health check middleware will process requests with a URL that matches the + /// provided value of case-insensitively, allowing for an extra trailing slash ('/') + /// character. + /// + /// + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path, string port, HealthCheckOptions options) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (path == null) + { + throw new ArgumentNullException(nameof(port)); + } + + if (!int.TryParse(port, out var portAsInt)) + { + throw new ArgumentException("The port must be a valid integer.", nameof(port)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + UseHealthChecksCore(app, path, portAsInt, new[] { Options.Create(options), }); + return app; + } + + private static void UseHealthChecksCore(IApplicationBuilder app, PathString path, int? port, object[] args) + { + // NOTE: we explicitly don't use Map here because it's really common for multiple health + // check middleware to overlap in paths. Ex: `/health`, `/health/detailed` - this is order + // sensititive with Map, and it's really surprising to people. + // + // See: + // https://github.com/aspnet/Diagnostics/issues/511 + // https://github.com/aspnet/Diagnostics/issues/512 + // https://github.com/aspnet/Diagnostics/issues/514 + + Func predicate = c => + { + return + + // Process the port if we have one + (port == null || c.Connection.LocalPort == port) && + + // We allow you to listen on all URLs by providing the empty PathString. + (!path.HasValue || + + // If you do provide a PathString, want to handle all of the special cases that + // StartsWithSegments handles, but we also want it to have exact match semantics. + // + // Ex: /Foo/ == /Foo (true) + // Ex: /Foo/Bar == /Foo (false) + (c.Request.Path.StartsWithSegments(path, out var remaining) && + string.IsNullOrEmpty(remaining))); + }; + + app.MapWhen(predicate, b => b.UseMiddleware(args)); + } + } +} diff --git a/src/Middleware/HealthChecks/src/HealthCheckMiddleware.cs b/src/Middleware/HealthChecks/src/HealthCheckMiddleware.cs new file mode 100644 index 0000000000..ba6b870bdc --- /dev/null +++ b/src/Middleware/HealthChecks/src/HealthCheckMiddleware.cs @@ -0,0 +1,125 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + public class HealthCheckMiddleware + { + private readonly RequestDelegate _next; + private readonly HealthCheckOptions _healthCheckOptions; + private readonly HealthCheckService _healthCheckService; + + public HealthCheckMiddleware( + RequestDelegate next, + IOptions healthCheckOptions, + HealthCheckService healthCheckService) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + if (healthCheckOptions == null) + { + throw new ArgumentNullException(nameof(healthCheckOptions)); + } + + if (healthCheckService == null) + { + throw new ArgumentNullException(nameof(healthCheckService)); + } + + _next = next; + _healthCheckOptions = healthCheckOptions.Value; + _healthCheckService = healthCheckService; + } + + /// + /// Processes a request. + /// + /// + /// + public async Task InvokeAsync(HttpContext httpContext) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + // Get results + var result = await _healthCheckService.CheckHealthAsync(_healthCheckOptions.Predicate, httpContext.RequestAborted); + + // Map status to response code - this is customizable via options. + if (!_healthCheckOptions.ResultStatusCodes.TryGetValue(result.Status, out var statusCode)) + { + var message = + $"No status code mapping found for {nameof(HealthStatus)} value: {result.Status}." + + $"{nameof(HealthCheckOptions)}.{nameof(HealthCheckOptions.ResultStatusCodes)} must contain" + + $"an entry for {result.Status}."; + + throw new InvalidOperationException(message); + } + + httpContext.Response.StatusCode = statusCode; + + if (!_healthCheckOptions.AllowCachingResponses) + { + // Similar to: https://github.com/aspnet/Security/blob/7b6c9cf0eeb149f2142dedd55a17430e7831ea99/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationHandler.cs#L377-L379 + var headers = httpContext.Response.Headers; + headers[HeaderNames.CacheControl] = "no-store, no-cache"; + headers[HeaderNames.Pragma] = "no-cache"; + headers[HeaderNames.Expires] = "Thu, 01 Jan 1970 00:00:00 GMT"; + } + + if (_healthCheckOptions.ResponseWriter != null) + { + await _healthCheckOptions.ResponseWriter(httpContext, result); + } + } + + private static IHealthCheck[] FilterHealthChecks( + IReadOnlyDictionary checks, + ISet names) + { + // If there are no filters then include all checks. + if (names.Count == 0) + { + return checks.Values.ToArray(); + } + + // Keep track of what we don't find so we can report errors. + var notFound = new HashSet(names, StringComparer.OrdinalIgnoreCase); + var matches = new List(); + + foreach (var kvp in checks) + { + if (!notFound.Remove(kvp.Key)) + { + // This check was excluded + continue; + } + + matches.Add(kvp.Value); + } + + if (notFound.Count > 0) + { + var message = + $"The following health checks were not found: '{string.Join(", ", notFound)}'. " + + $"Registered health checks: '{string.Join(", ", checks.Keys)}'."; + throw new InvalidOperationException(message); + } + + return matches.ToArray(); + } + } +} diff --git a/src/Middleware/HealthChecks/src/HealthCheckOptions.cs b/src/Middleware/HealthChecks/src/HealthCheckOptions.cs new file mode 100644 index 0000000000..16b81c2b96 --- /dev/null +++ b/src/Middleware/HealthChecks/src/HealthCheckOptions.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + /// + /// Contains options for the . + /// + public class HealthCheckOptions + { + /// + /// Gets or sets a predicate that is used to filter the set of health checks executed. + /// + /// + /// If is null, the will run all + /// registered health checks - this is the default behavior. To run a subset of health checks, + /// provide a function that filters the set of checks. + /// + public Func Predicate { get; set; } + + /// + /// Gets a dictionary mapping the to an HTTP status code applied to the response. + /// This property can be used to configure the status codes returned for each status. + /// + public IDictionary ResultStatusCodes { get; } = new Dictionary() + { + { HealthStatus.Healthy, StatusCodes.Status200OK }, + { HealthStatus.Degraded, StatusCodes.Status200OK }, + { HealthStatus.Unhealthy, StatusCodes.Status503ServiceUnavailable }, + }; + + /// + /// Gets or sets a delegate used to write the response. + /// + /// + /// The default value is a delegate that will write a minimal text/plain response with the value + /// of as a string. + /// + public Func ResponseWriter { get; set; } = HealthCheckResponseWriters.WriteMinimalPlaintext; + + /// + /// Gets or sets a value that controls whether responses from the health check middleware can be cached. + /// + /// + /// + /// The health check middleware does not perform caching of any kind. This setting configures whether + /// the middleware will apply headers to the HTTP response that instruct clients to avoid caching. + /// + /// + /// If the value is false the health check middleware will set or override the + /// Cache-Control, Expires, and Pragma headers to prevent response caching. If the value + /// is true the health check middleware will not modify the cache headers of the response. + /// + /// + public bool AllowCachingResponses { get; set; } + } +} diff --git a/src/Middleware/HealthChecks/src/HealthCheckResponseWriters.cs b/src/Middleware/HealthChecks/src/HealthCheckResponseWriters.cs new file mode 100644 index 0000000000..50cb8c736e --- /dev/null +++ b/src/Middleware/HealthChecks/src/HealthCheckResponseWriters.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + internal static class HealthCheckResponseWriters + { + public static Task WriteMinimalPlaintext(HttpContext httpContext, HealthReport result) + { + httpContext.Response.ContentType = "text/plain"; + return httpContext.Response.WriteAsync(result.Status.ToString()); + } + } +} diff --git a/src/Middleware/HealthChecks/src/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj b/src/Middleware/HealthChecks/src/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj new file mode 100644 index 0000000000..6eecefa7dd --- /dev/null +++ b/src/Middleware/HealthChecks/src/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj @@ -0,0 +1,25 @@ + + + + ASP.NET Core middleware for returning the results of Health Checks in the application + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + ASP.NET Core middleware for returning the results of Health Checks in the application + + + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + + + + + + + + + + diff --git a/src/Middleware/HealthChecks/src/baseline.netcore.json b/src/Middleware/HealthChecks/src/baseline.netcore.json new file mode 100644 index 0000000000..d089d2470d --- /dev/null +++ b/src/Middleware/HealthChecks/src/baseline.netcore.json @@ -0,0 +1,5 @@ +{ + "AssemblyIdentity": "Microsoft.AspNetCore.Diagnostics.HealthChecks, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", + "Types": [ + ] +} \ No newline at end of file diff --git a/src/Middleware/HealthChecks/test/UnitTests/HealthCheckMiddlewareSampleTest.cs b/src/Middleware/HealthChecks/test/UnitTests/HealthCheckMiddlewareSampleTest.cs new file mode 100644 index 0000000000..bffed9ed66 --- /dev/null +++ b/src/Middleware/HealthChecks/test/UnitTests/HealthCheckMiddlewareSampleTest.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Xunit; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + public class HealthCheckMiddlewareSampleTest + { + [Fact] + public async Task BasicStartup() + { + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CustomWriterStartup() + { + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.ToString()); + + // Ignoring the body since it contains a bunch of statistics + } + + [Fact] + public async Task LivenessProbeStartup_Liveness() + { + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health/live"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task LivenessProbeStartup_Readiness() + { + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health/ready"); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Unhealthy", await response.Content.ReadAsStringAsync()); + } + } +} diff --git a/src/Middleware/HealthChecks/test/UnitTests/HealthCheckMiddlewareTests.cs b/src/Middleware/HealthChecks/test/UnitTests/HealthCheckMiddlewareTests.cs new file mode 100644 index 0000000000..9bcefafbaa --- /dev/null +++ b/src/Middleware/HealthChecks/test/UnitTests/HealthCheckMiddlewareTests.cs @@ -0,0 +1,688 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + public class HealthCheckMiddlewareTests + { + [Fact] // Matches based on '.Map' + public async Task IgnoresRequestThatDoesNotMatchPath() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/frob"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] // Matches based on '.Map' + public async Task MatchIsCaseInsensitive() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/HEALTH"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ReturnsPlainTextStatus() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StatusCodeIs200IfNoChecks() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StatusCodeIs200IfAllChecksHealthy() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => HealthCheckResult.Healthy("A-ok!")) + .AddCheck("Bar", () => HealthCheckResult.Healthy("A-ok!")) + .AddCheck("Baz", () => HealthCheckResult.Healthy("A-ok!")); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StatusCodeIs200IfCheckIsDegraded() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => HealthCheckResult.Healthy("A-ok!")) + .AddCheck("Bar", () => HealthCheckResult.Degraded("Not so great.")) + .AddCheck("Baz", () => HealthCheckResult.Healthy("A-ok!")); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Degraded", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StatusCodeIs503IfCheckIsUnhealthy() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddAsyncCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("Pretty bad."))) + .AddAsyncCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Unhealthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StatusCodeIs503IfCheckHasUnhandledException() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddAsyncCheck("Bar", () => throw null) + .AddAsyncCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Unhealthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanUseCustomWriter() + { + var expectedJson = JsonConvert.SerializeObject(new + { + status = "Unhealthy", + }); + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health", new HealthCheckOptions() + { + ResponseWriter = (c, r) => + { + var json = JsonConvert.SerializeObject(new { status = r.Status.ToString(), }); + c.Response.ContentType = "application/json"; + return c.Response.WriteAsync(json); + }, + }); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddAsyncCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("Pretty bad."))) + .AddAsyncCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal("application/json", response.Content.Headers.ContentType.ToString()); + + var result = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedJson, result); + } + + [Fact] + public async Task NoResponseWriterReturnsEmptyBody() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health", new HealthCheckOptions() + { + ResponseWriter = null, + }); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddAsyncCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("Pretty bad."))) + .AddAsyncCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanSetCustomStatusCodes() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health", new HealthCheckOptions() + { + ResultStatusCodes = + { + [HealthStatus.Healthy] = 201, + } + }); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task SetsCacheHeaders() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + Assert.Equal("no-store, no-cache", response.Headers.CacheControl.ToString()); + Assert.Equal("no-cache", response.Headers.Pragma.ToString()); + Assert.Equal(new string[] { "Thu, 01 Jan 1970 00:00:00 GMT" }, response.Content.Headers.GetValues(HeaderNames.Expires)); + } + + [Fact] + public async Task CanSuppressCacheHeaders() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health", new HealthCheckOptions() + { + AllowCachingResponses = true, + }); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + Assert.Null(response.Headers.CacheControl); + Assert.Empty(response.Headers.Pragma.ToString()); + Assert.False(response.Content.Headers.Contains(HeaderNames.Expires)); + } + + [Fact] + public async Task CanFilterChecks() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health", new HealthCheckOptions() + { + Predicate = (check) => check.Name == "Foo" || check.Name == "Baz", + }); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + // Will get filtered out + .AddAsyncCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("A-ok!"))) + .AddAsyncCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenWithoutPath_AcceptsRequest() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks(default); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenWithPath_AcceptsRequestWithExtraSlash() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health/"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CanListenWithPath_AcceptsRequestWithCaseInsensitiveMatch() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/HEALTH"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenWithPath_RejectsRequestWithExtraSegments() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health/detailed"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // See: https://github.com/aspnet/Diagnostics/issues/511 + [Fact] + public async Task CanListenWithPath_MultipleMiddleware_LeastSpecificFirst() + { + var builder = new WebHostBuilder() + .Configure(app => + { + // Throws if used + app.UseHealthChecks("/health", new HealthCheckOptions() + { + ResponseWriter = (c, r) => throw null, + }); + + app.UseHealthChecks("/health/detailed"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health/detailed"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + // See: https://github.com/aspnet/Diagnostics/issues/511 + [Fact] + public async Task CanListenWithPath_MultipleMiddleware_MostSpecificFirst() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health/detailed"); + + // Throws if used + app.UseHealthChecks("/health", new HealthCheckOptions() + { + ResponseWriter = (c, r) => throw null, + }); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health/detailed"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenOnPort_AcceptsRequest_OnSpecifiedPort() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(next => async (context) => + { + // Need to fake setting the connection info. TestServer doesn't + // do that, because it doesn't have a connection. + context.Connection.LocalPort = context.Request.Host.Port.Value; + await next(context); + }); + + app.UseHealthChecks("/health", port: 5001); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenOnPortWithoutPath_AcceptsRequest_OnSpecifiedPort() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(next => async (context) => + { + // Need to fake setting the connection info. TestServer doesn't + // do that, because it doesn't have a connection. + context.Connection.LocalPort = context.Request.Host.Port.Value; + await next(context); + }); + + app.UseHealthChecks(default, port: 5001); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenOnPort_RejectsRequest_OnOtherPort() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(next => async (context) => + { + // Need to fake setting the connection info. TestServer doesn't + // do that, because it doesn't have a connection. + context.Connection.LocalPort = context.Request.Host.Port.Value; + await next(context); + }); + + app.UseHealthChecks("/health", port: 5001); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5000/health"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CanListenOnPort_MultipleMiddleware() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(next => async (context) => + { + // Need to fake setting the connection info. TestServer doesn't + // do that, because it doesn't have a connection. + context.Connection.LocalPort = context.Request.Host.Port.Value; + await next(context); + }); + + // Throws if used + app.UseHealthChecks("/health", port: 5001, new HealthCheckOptions() + { + ResponseWriter = (c, r) => throw null, + }); + + app.UseHealthChecks("/health/detailed", port: 5001); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health/detailed"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanListenOnPort_MultipleMiddleware_DifferentPorts() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(next => async (context) => + { + // Need to fake setting the connection info. TestServer doesn't + // do that, because it doesn't have a connection. + context.Connection.LocalPort = context.Request.Host.Port.Value; + await next(context); + }); + + // Throws if used + app.UseHealthChecks("/health", port: 5002, new HealthCheckOptions() + { + ResponseWriter = (c, r) => throw null, + }); + + app.UseHealthChecks("/health", port: 5001); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("http://localhost:5001/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + } +} diff --git a/src/Middleware/HealthChecks/test/UnitTests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj b/src/Middleware/HealthChecks/test/UnitTests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj new file mode 100644 index 0000000000..ec431460cc --- /dev/null +++ b/src/Middleware/HealthChecks/test/UnitTests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj @@ -0,0 +1,18 @@ + + + + $(StandardTestTfms) + Microsoft.AspNetCore.Diagnostics.HealthChecks + + + + + + + + + + + + + diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/BasicStartup.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/BasicStartup.cs new file mode 100644 index 0000000000..c89d78c8e5 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/BasicStartup.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthChecksSample +{ + // Pass in `--scenario basic` at the command line to run this sample. + public class BasicStartup + { + public void ConfigureServices(IServiceCollection services) + { + // Registers required services for health checks + services.AddHealthChecks(); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + // This will register the health checks middleware at the URL /health. + // + // By default health checks will return a 200 with 'Healthy'. + // - No health checks are registered by default, the app is healthy if it is reachable + // - The default response writer writes the HealthCheckStatus as text/plain content + // + // This is the simplest way to use health checks, it is suitable for systems + // that want to check for 'liveness' of an application. + app.UseHealthChecks("/health"); + + app.Run(async (context) => + { + await context.Response.WriteAsync("Go to /health to see the health status"); + }); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/CustomWriterStartup.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/CustomWriterStartup.cs new file mode 100644 index 0000000000..6e37b1c6ff --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/CustomWriterStartup.cs @@ -0,0 +1,58 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace HealthChecksSample +{ + // Pass in `--scenario writer` at the command line to run this sample. + public class CustomWriterStartup + { + public void ConfigureServices(IServiceCollection services) + { + // Registers required services for health checks + services.AddHealthChecks() + + // Registers a custom health check implementation + .AddGCInfoCheck("GCInfo"); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + // This will register the health checks middleware at the URL /health + // + // This example overrides the HealthCheckResponseWriter to write the health + // check result in a totally custom way. + app.UseHealthChecks("/health", new HealthCheckOptions() + { + // This custom writer formats the detailed status as JSON. + ResponseWriter = WriteResponse, + }); + + app.Run(async (context) => + { + await context.Response.WriteAsync("Go to /health to see the health status"); + }); + } + + private static Task WriteResponse(HttpContext httpContext, HealthReport result) + { + httpContext.Response.ContentType = "application/json"; + + var json = new JObject( + new JProperty("status", result.Status.ToString()), + new JProperty("results", new JObject(result.Entries.Select(pair => + new JProperty(pair.Key, new JObject( + new JProperty("status", pair.Value.Status.ToString()), + new JProperty("description", pair.Value.Description), + new JProperty("data", new JObject(pair.Value.Data.Select(p => new JProperty(p.Key, p.Value)))))))))); + return httpContext.Response.WriteAsync(json.ToString(Formatting.Indented)); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DBHealthStartup.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DBHealthStartup.cs new file mode 100644 index 0000000000..46639d26ea --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DBHealthStartup.cs @@ -0,0 +1,46 @@ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthChecksSample +{ + // Pass in `--scenario db` at the command line to run this sample. + public class DbHealthStartup + { + public DbHealthStartup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + // Registers required services for health checks + services.AddHealthChecks() + // Add a health check for a SQL database + .AddCheck("MyDatabase", new SqlConnectionHealthCheck(Configuration["ConnectionStrings:DefaultConnection"])); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + // This will register the health checks middleware at the URL /health. + // + // By default health checks will return a 200 with 'Healthy' when the database is responsive + // - We've registered a SqlConnectionHealthCheck + // - The default response writer writes the HealthCheckStatus as text/plain content + // + // This is the simplest way to use health checks, it is suitable for systems + // that want to check for 'liveness' of an application with a database. + app.UseHealthChecks("/health"); + + app.Run(async (context) => + { + await context.Response.WriteAsync("Go to /health to see the health status"); + }); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DbConnectionHealthCheck.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DbConnectionHealthCheck.cs new file mode 100644 index 0000000000..cb865f67c0 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DbConnectionHealthCheck.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace HealthChecksSample +{ + public abstract class DbConnectionHealthCheck : IHealthCheck + { + protected DbConnectionHealthCheck(string connectionString) + : this(connectionString, testQuery: null) + { + } + + protected DbConnectionHealthCheck(string connectionString, string testQuery) + { + ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + TestQuery = testQuery; + } + + protected string ConnectionString { get; } + + // This sample supports specifying a query to run as a boolean test of whether the database + // is responding. It is important to choose a query that will return quickly or you risk + // overloading the database. + // + // In most cases this is not necessary, but if you find it necessary, choose a simple query such as 'SELECT 1'. + protected string TestQuery { get; } + + protected abstract DbConnection CreateConnection(string connectionString); + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default(CancellationToken)) + { + using (var connection = CreateConnection(ConnectionString)) + { + try + { + await connection.OpenAsync(cancellationToken); + + if (TestQuery != null) + { + var command = connection.CreateCommand(); + command.CommandText = TestQuery; + + await command.ExecuteNonQueryAsync(cancellationToken); + } + } + catch (DbException ex) + { + return new HealthCheckResult(status: context.Registration.FailureStatus, exception: ex); + } + } + + return HealthCheckResult.Healthy(); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DbContextHealthStartup.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DbContextHealthStartup.cs new file mode 100644 index 0000000000..5f04ea0ea0 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/DbContextHealthStartup.cs @@ -0,0 +1,85 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthChecksSample +{ + // Pass in `--scenario dbcontext` at the command line to run this sample. + public class DbContextHealthStartup + { + public DbContextHealthStartup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + // Registers required services for health checks + services.AddHealthChecks() + + // Registers a health check for the MyContext type. By default the name of the health check will be the + // name of the DbContext type. There are other options available through AddDbContextCheck to configure + // failure status, tags, and custom test query. + .AddDbContextCheck(); + + // Registers the MyContext type and configures the database provider. + // + // The health check added by AddDbContextCheck will create instances of MyContext from the service provider, + // and so will reuse the configuration provided here. + services.AddDbContext(options => + { + options.UseSqlServer(Configuration["ConnectionStrings:DefaultConnection"]); + }); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + // This will register the health checks middleware at the URL /health. + // + // Since this sample doesn't do anything to create the database by default, this will + // return unhealthy by default. + // + // You can to to /createdatabase and /deletedatabase to create and delete the database + // (respectively), and see how it immediately effects the health status. + // + app.UseHealthChecks("/health"); + + app.Map("/createdatabase", b => b.Run(async (context) => + { + await context.Response.WriteAsync("Creating the database...\n"); + await context.Response.Body.FlushAsync(); + + var myContext = context.RequestServices.GetRequiredService(); + await myContext.Database.EnsureCreatedAsync(); + + await context.Response.WriteAsync("Done\n"); + await context.Response.WriteAsync("Go to /health to see the health status\n"); + })); + + app.Map("/deletedatabase", b => b.Run(async (context) => + { + await context.Response.WriteAsync("Deleting the database...\n"); + await context.Response.Body.FlushAsync(); + + var myContext = context.RequestServices.GetRequiredService(); + await myContext.Database.EnsureDeletedAsync(); + + await context.Response.WriteAsync("Done\n"); + await context.Response.WriteAsync("Go to /health to see the health status\n"); + })); + + app.Run(async (context) => + { + await context.Response.WriteAsync("Go to /health to see the health status\n"); + await context.Response.WriteAsync("Go to /createdatabase to create the database\n"); + await context.Response.WriteAsync("Go to /deletedatabase to delete the database\n"); + }); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/GCInfoHealthCheck.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/GCInfoHealthCheck.cs new file mode 100644 index 0000000000..91519af452 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/GCInfoHealthCheck.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace HealthChecksSample +{ + // This is an example of a custom health check that implements IHealthCheck. + // + // This example also shows a technique for authoring a health check that needs to be registered + // with additional configuration data. This technique works via named options, and is useful + // for authoring health checks that can be disctributed as libraries. + + public static class GCInfoHealthCheckBuilderExtensions + { + public static IHealthChecksBuilder AddGCInfoCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus = null, + IEnumerable tags = null, + long? thresholdInBytes = null) + { + // Register a check of type GCInfo + builder.AddCheck(name, failureStatus ?? HealthStatus.Degraded, tags); + + // Configure named options to pass the threshold into the check. + if (thresholdInBytes.HasValue) + { + builder.Services.Configure(name, options => + { + options.Threshold = thresholdInBytes.Value; + }); + } + + return builder; + } + } + + public class GCInfoHealthCheck : IHealthCheck + { + private readonly IOptionsMonitor _options; + + public GCInfoHealthCheck(IOptionsMonitor options) + { + _options = options; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default(CancellationToken)) + { + var options = _options.Get(context.Registration.Name); + + // This example will report degraded status if the application is using + // more than the configured amount of memory (1gb by default). + // + // Additionally we include some GC info in the reported diagnostics. + var allocated = GC.GetTotalMemory(forceFullCollection: false); + var data = new Dictionary() + { + { "Allocated", allocated }, + { "Gen0Collections", GC.CollectionCount(0) }, + { "Gen1Collections", GC.CollectionCount(1) }, + { "Gen2Collections", GC.CollectionCount(2) }, + }; + + // Report failure if the allocated memory is >= the threshold. + // + // Using context.Registration.FailureStatus means that the application developer can configure + // how they want failures to appear. + var result = allocated >= options.Threshold ? context.Registration.FailureStatus : HealthStatus.Healthy; + + return Task.FromResult(new HealthCheckResult( + result, + description: "reports degraded status if allocated bytes >= 1gb", + data: data)); + } + } + + public class GCInfoOptions + { + // The failure threshold (in bytes) + public long Threshold { get; set; } = 1024L * 1024L * 1024L; + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/HealthChecksSample.csproj b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/HealthChecksSample.csproj new file mode 100644 index 0000000000..6a8939c3e5 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/HealthChecksSample.csproj @@ -0,0 +1,22 @@ + + + + + netcoreapp2.2;net461 + netcoreapp2.2 + + + + + + + + + + + + + + + + diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/LivenessProbeStartup.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/LivenessProbeStartup.cs new file mode 100644 index 0000000000..82676221ce --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/LivenessProbeStartup.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthChecksSample +{ + // Pass in `--scenario liveness` at the command line to run this sample. + public class LivenessProbeStartup + { + public void ConfigureServices(IServiceCollection services) + { + // Registers required services for health checks + services + .AddHealthChecks() + .AddCheck("Slow", failureStatus: null, tags: new[] { "ready", }); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + // This will register the health checks middleware twice: + // - at /health/ready for 'readiness' + // - at /health/live for 'liveness' + // + // Using a separate liveness and readiness check is useful in an environment like Kubernetes + // when an application needs to do significant work before accepting requests. Using separate + // checks allows the orchestrator to distinguish whether the application is functioning but + // not yet ready or if the application has failed to start. + // + // For instance the liveness check will do a quick set of checks to determine if the process + // is functioning correctly. + // + // The readiness check might do a set of more expensive or time-consuming checks to determine + // if all other resources are responding. + // + // See https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/ for + // more details about readiness and liveness probes in Kubernetes. + // + // In this example, the liveness check will us an 'identity' check that always returns healthy. + // + // In this example, the readiness check will run all registered checks, include a check with a + // long initialization time (15 seconds). + + + // The readiness check uses all registered checks with the 'ready' tag. + app.UseHealthChecks("/health/ready", new HealthCheckOptions() + { + Predicate = (check) => check.Tags.Contains("ready"), + }); + + // The liveness filters out all checks and just returns success + app.UseHealthChecks("/health/live", new HealthCheckOptions() + { + // Exclude all checks, just return a 200. + Predicate = (check) => false, + }); + + app.Run(async (context) => + { + await context.Response.WriteAsync("Go to /health/ready to see the readiness status"); + await context.Response.WriteAsync(Environment.NewLine); + await context.Response.WriteAsync("Go to /health/live to see the liveness status"); + }); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/ManagementPortStartup.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/ManagementPortStartup.cs new file mode 100644 index 0000000000..79b38dc875 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/ManagementPortStartup.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthChecksSample +{ + // Pass in `--scenario port` at the command line to run this sample. + public class ManagementPortStartup + { + public ManagementPortStartup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + // Registers required services for health checks + services.AddHealthChecks(); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + // This will register the health checks middleware at the URL /health but only on the specified port. + // + // By default health checks will return a 200 with 'Healthy'. + // - No health checks are registered by default, the app is healthy if it is reachable + // - The default response writer writes the HealthCheckStatus as text/plain content + // + // Use UseHealthChecks with a port will only process health checks requests on connection + // to the specified port. This is typically used in a container environment where you can expose + // a port for monitoring services to have access to the service. + // - In this case the management is configured in the launchSettings.json and passed through + // an environment variable + // - Additionally, the server is also configured to listen to requests on the management port. + app.UseHealthChecks("/health", port: Configuration["ManagementPort"]); + + app.Run(async (context) => + { + await context.Response.WriteAsync($"Go to http://localhost:{Configuration["ManagementPort"]}/health to see the health status"); + }); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/MyContext.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/MyContext.cs new file mode 100644 index 0000000000..1cfbeaab25 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/MyContext.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace HealthChecksSample +{ + public class MyContext : DbContext + { + public MyContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Blog { get; set; } + } + + public class Blog + { + public int BlogId { get; set; } + public string Url { get; set; } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/Program.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/Program.cs new file mode 100644 index 0000000000..56e6e6478f --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/Program.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace HealthChecksSample +{ + public class Program + { + private static readonly Dictionary _scenarios; + + static Program() + { + _scenarios = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "", typeof(BasicStartup) }, + { "basic", typeof(BasicStartup) }, + { "writer", typeof(CustomWriterStartup) }, + { "liveness", typeof(LivenessProbeStartup) }, + { "port", typeof(ManagementPortStartup) }, + { "db", typeof(DbHealthStartup) }, + { "dbcontext", typeof(DbContextHealthStartup) }, + }; + } + + public static void Main(string[] args) + { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) + { + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .AddEnvironmentVariables(prefix: "ASPNETCORE_") + .AddCommandLine(args) + .Build(); + + var scenario = config["scenario"] ?? string.Empty; + if (!_scenarios.TryGetValue(scenario, out var startupType)) + { + startupType = typeof(BasicStartup); + } + + return new WebHostBuilder() + .UseConfiguration(config) + .ConfigureLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + builder.AddConfiguration(config); + builder.AddConsole(); + }) + .UseKestrel() + .UseStartup(startupType) + .Build(); + } + + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/Properties/launchSettings.json b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/Properties/launchSettings.json new file mode 100644 index 0000000000..6afb19b2b3 --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "HealthChecksSample": { + "commandName": "Project", + "commandLineArgs": "", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5000/;http://localhost:5001/", + "ASPNETCORE_MANAGEMENTPORT": "5001" + }, + "applicationUrl": "http://localhost:5000/" + } + } +} \ No newline at end of file diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/SlowDependencyHealthCheck.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/SlowDependencyHealthCheck.cs new file mode 100644 index 0000000000..e14aeb210c --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/SlowDependencyHealthCheck.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace HealthChecksSample +{ + // Simulates a health check for an application dependency that takes a while to initialize. + // This is part of the readiness/liveness probe sample. + public class SlowDependencyHealthCheck : IHealthCheck + { + public static readonly string HealthCheckName = "slow_dependency"; + + private readonly Task _task; + + public SlowDependencyHealthCheck() + { + _task = Task.Delay(15 * 1000); + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default(CancellationToken)) + { + if (_task.IsCompleted) + { + return Task.FromResult(HealthCheckResult.Healthy("Dependency is ready")); + } + + return Task.FromResult(new HealthCheckResult( + status: context.Registration.FailureStatus, + description: "Dependency is still initializing")); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/SqlConnectionHealthCheck.cs b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/SqlConnectionHealthCheck.cs new file mode 100644 index 0000000000..fcc135ad1e --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/SqlConnectionHealthCheck.cs @@ -0,0 +1,25 @@ +using System.Data.Common; +using System.Data.SqlClient; + +namespace HealthChecksSample +{ + public class SqlConnectionHealthCheck : DbConnectionHealthCheck + { + private static readonly string DefaultTestQuery = "Select 1"; + + public SqlConnectionHealthCheck(string connectionString) + : this(connectionString, testQuery: DefaultTestQuery) + { + } + + public SqlConnectionHealthCheck(string connectionString, string testQuery) + : base(connectionString, testQuery ?? DefaultTestQuery) + { + } + + protected override DbConnection CreateConnection(string connectionString) + { + return new SqlConnection(connectionString); + } + } +} diff --git a/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/appsettings.json b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/appsettings.json new file mode 100644 index 0000000000..21b2dcdbfd --- /dev/null +++ b/src/Middleware/HealthChecks/test/testassets/HealthChecksSample/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=HealthCheckSample;Trusted_Connection=True;MultipleActiveResultSets=true;ConnectRetryCount=0" + }, + "Logging": { + "LogLevel": { + "Default": "Debug" + }, + "Console": { + "IncludeScopes": "true" + } + } +} \ No newline at end of file diff --git a/src/Middleware/MiddlewareAnalysis/samples/MiddlewareAnalysisSample/MiddlewareAnalysisSample.csproj b/src/Middleware/MiddlewareAnalysis/samples/MiddlewareAnalysisSample/MiddlewareAnalysisSample.csproj index 31a5713044..18875ae57d 100644 --- a/src/Middleware/MiddlewareAnalysis/samples/MiddlewareAnalysisSample/MiddlewareAnalysisSample.csproj +++ b/src/Middleware/MiddlewareAnalysis/samples/MiddlewareAnalysisSample/MiddlewareAnalysisSample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0;netcoreapp2.1 + net461;netcoreapp2.2