From 7251cf52b202e0225e965f155247504d9aa2a281 Mon Sep 17 00:00:00 2001 From: KDCLLC Date: Thu, 24 Oct 2019 00:54:50 -0400 Subject: [PATCH] moving the healthchecks to a Extensions issue #84 --- README.md | 1 + .../AzureBlobStorageHealthCheck.cs | 149 +++++++++++++ .../AzureBlobStorage/StorageAccountOptions.cs | 25 +++ .../Bet.Extensions.HealthChecks.csproj | 13 ++ .../SslCertificateHealthCheck.cs | 39 ++++ .../HealthChecksBuilderExtensions.cs | 209 +++++++++++++++++- .../MemoryCheck/MemoryCheckOptions.cs | 10 + .../MemoryCheck/MemoryHealthCheck.cs | 48 ++++ src/Bet.Extensions.HealthChecks/README.md | 62 ++++++ .../SigtermCheck/SigtermHealthCheck.cs | 41 ++++ .../UriCheck/IUriHealthCheckBuilder.cs | 20 ++ .../UriCheck/IUriOptionsSetup.cs | 64 ++++++ .../UriCheck/UriHealthCheck.cs | 88 ++++++++ .../UriCheck/UriHealthCheckBuilder.cs | 54 +++++ .../UriCheck/UriHealthCheckOptions.cs | 9 + .../UriCheck/UriOptionsSetup.cs | 105 +++++++++ .../HealthCheckPublishersTests.cs | 13 +- 17 files changed, 944 insertions(+), 6 deletions(-) create mode 100644 src/Bet.Extensions.HealthChecks/AzureBlobStorage/AzureBlobStorageHealthCheck.cs create mode 100644 src/Bet.Extensions.HealthChecks/AzureBlobStorage/StorageAccountOptions.cs create mode 100644 src/Bet.Extensions.HealthChecks/CertificateCheck/SslCertificateHealthCheck.cs create mode 100644 src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryCheckOptions.cs create mode 100644 src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryHealthCheck.cs create mode 100644 src/Bet.Extensions.HealthChecks/README.md create mode 100644 src/Bet.Extensions.HealthChecks/SigtermCheck/SigtermHealthCheck.cs create mode 100644 src/Bet.Extensions.HealthChecks/UriCheck/IUriHealthCheckBuilder.cs create mode 100644 src/Bet.Extensions.HealthChecks/UriCheck/IUriOptionsSetup.cs create mode 100644 src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheck.cs create mode 100644 src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckBuilder.cs create mode 100644 src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckOptions.cs create mode 100644 src/Bet.Extensions.HealthChecks/UriCheck/UriOptionsSetup.cs diff --git a/README.md b/README.md index 3bbdfc8..5d4e1fc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This repo contains several projects that provide with extended functionality for 4. [`Bet.Extensions.Hosting`](./src/Bet.Extensions.Hosting/README.md) - extends generic functionality for `IHost`. 5. [`Bet.Extensions.AzureVault`](./src/Bet.Extensions.AzureVault/README.md) - includes Azure Vault functionality. 6. [`Bet.Extensions.AzureStorage`](./src/Bet.Extensions.AzureStorage/README.md) - includes MSI and regular access to Azure Storage Blob or Queue. +7. [`Bet.Extensions.HealthChecks`](./src/Bet.Extensions.HealthChecks/README.md) - many useful HealChecks for Kubernetes. ## AspNetCore specific functionality diff --git a/src/Bet.Extensions.HealthChecks/AzureBlobStorage/AzureBlobStorageHealthCheck.cs b/src/Bet.Extensions.HealthChecks/AzureBlobStorage/AzureBlobStorageHealthCheck.cs new file mode 100644 index 0000000..b778cb4 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/AzureBlobStorage/AzureBlobStorageHealthCheck.cs @@ -0,0 +1,149 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.Services.AppAuthentication; +using Microsoft.Azure.Storage; +using Microsoft.Azure.Storage.Auth; +using Microsoft.Azure.Storage.Blob; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bet.Extensions.HealthChecks.AzureBlobStorage +{ + public class AzureBlobStorageHealthCheck : IHealthCheck + { + private IOptionsMonitor _options; + private ILogger _logger; + + public AzureBlobStorageHealthCheck( + IOptionsMonitor options, + ILogger logger) + { + _options = options; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var checkName = context.Registration.Name; + var options = _options.Get(checkName); + var fullCheckName = $"{checkName}-{options?.ContainerName}"; + + try + { + var cloudStorageAccount = await GetStorageAccountAsync(options); + var cloudBlobClient = cloudStorageAccount.CreateCloudBlobClient(); + + var cloudBlobContainer = cloudBlobClient.GetContainerReference(options.ContainerName); + + _logger.LogInformation("[HealthCheck][{healthCheckName}]", fullCheckName); + + await cloudBlobContainer.CreateIfNotExistsAsync(cancellationToken); + + return new HealthCheckResult(HealthStatus.Healthy, fullCheckName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[HealthCheck][{healthCheckName}]", fullCheckName); + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } + + private async Task TokenRenewerAsync(object state, CancellationToken cancellationToken) + { + var sw = ValueStopwatch.StartNew(); + + cancellationToken.ThrowIfCancellationRequested(); + + var (authResult, next) = await GetToken(state); + + _logger.LogInformation("Azure Storage Authentication duration: {0}", sw.GetElapsedTime().TotalSeconds); + + if (next.Ticks < 0) + { + next = default; + + _logger.LogInformation("Azure Storage Authentication Renewing Token..."); + + var swr = ValueStopwatch.StartNew(); + + (authResult, next) = await GetToken(state); + + _logger.LogInformation("Azure Storage Authentication Renewing Token duration: {0}", swr.GetElapsedTime().TotalSeconds); + } + + // Return the new token and the next refresh time. + return new NewTokenAndFrequency(authResult.AccessToken, next); + } + + // https://github.com/MicrosoftDocs/azure-docs/blob/941ccc038829a0be5fa8515ffba9956cfc02a5e6/articles/storage/common/storage-auth-aad-msi.md + private async Task<(AppAuthenticationResult result, TimeSpan ticks)> GetToken(object state) + { + // Specify the resource ID for requesting Azure AD tokens for Azure Storage. + const string StorageResource = "https://storage.azure.com/"; + + // Use the same token provider to request a new token. + var authResult = await ((AzureServiceTokenProvider)state).GetAuthenticationResultAsync(StorageResource); + + // Renew the token 5 minutes before it expires. + var next = authResult.ExpiresOn - DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); + + // debug purposes + // var next = (authResult.ExpiresOn - authResult.ExpiresOn) + TimeSpan.FromSeconds(15); + return (authResult, next); + } + + private async Task GetStorageAccountAsync(StorageAccountOptions options, CancellationToken cancellationToken = default) + { + CloudStorageAccount account; + + if (!string.IsNullOrEmpty(options.ConnectionString) + && CloudStorageAccount.TryParse(options.ConnectionString, out var cloudStorageAccount)) + { + account = cloudStorageAccount; + + _logger.LogInformation("Azure Storage Authentication with ConnectionString."); + } + else if (!string.IsNullOrEmpty(options.Name) + && string.IsNullOrEmpty(options.Token)) + { + // Get the initial access token and the interval at which to refresh it. + var azureServiceTokenProvider = new AzureServiceTokenProvider(); + var tokenAndFrequency = await TokenRenewerAsync( + azureServiceTokenProvider, + cancellationToken); + + // Create storage credentials using the initial token, and connect the callback function + // to renew the token just before it expires +#pragma warning disable CA2000 // Dispose objects before losing scope + var tokenCredential = new TokenCredential( + tokenAndFrequency.Token, + TokenRenewerAsync, + azureServiceTokenProvider, + tokenAndFrequency.Frequency.Value); +#pragma warning restore CA2000 // Dispose objects before losing scope + + var storageCredentials = new StorageCredentials(tokenCredential); + + account = new CloudStorageAccount(storageCredentials, options.Name, string.Empty, true); + + _logger.LogInformation("Azure Storage Authentication with MSI Token."); + } + else if (!string.IsNullOrEmpty(options.Name) + && !string.IsNullOrEmpty(options.Token)) + { + account = new CloudStorageAccount(new StorageCredentials(options.Token), options.Name, true); + _logger.LogInformation("Azure Storage Authentication with SAS Token."); + } + else + { + throw new ArgumentException($"One of the following must be set: '{options.ConnectionString}' or '{options.Name}'!"); + } + + return account; + } + } +} diff --git a/src/Bet.Extensions.HealthChecks/AzureBlobStorage/StorageAccountOptions.cs b/src/Bet.Extensions.HealthChecks/AzureBlobStorage/StorageAccountOptions.cs new file mode 100644 index 0000000..396866d --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/AzureBlobStorage/StorageAccountOptions.cs @@ -0,0 +1,25 @@ +using Microsoft.Azure.Services.AppAuthentication; + +namespace Bet.Extensions.HealthChecks.AzureBlobStorage +{ + public class StorageAccountOptions + { + /// + /// Azure Storage Connection String. + /// + public string ConnectionString { get; set; } + + /// + /// Name of the Storage Account. Used with . + /// This is enable only if ConnectionString is empty or null. + /// + public string Name { get; set; } + + /// + /// SAS Token used with Name of the Storage Account. + /// + public string Token { get; set; } + + internal string ContainerName { get; set; } + } +} diff --git a/src/Bet.Extensions.HealthChecks/Bet.Extensions.HealthChecks.csproj b/src/Bet.Extensions.HealthChecks/Bet.Extensions.HealthChecks.csproj index e5c51dd..9b66562 100644 --- a/src/Bet.Extensions.HealthChecks/Bet.Extensions.HealthChecks.csproj +++ b/src/Bet.Extensions.HealthChecks/Bet.Extensions.HealthChecks.csproj @@ -4,8 +4,21 @@ netstandard2.0;netstandard2.1 + + The collection of the HealthChecks functionality for Worker Pods. + dotnetcore, worker, healthcheck, hosting, extensions + + + + + + + + + + diff --git a/src/Bet.Extensions.HealthChecks/CertificateCheck/SslCertificateHealthCheck.cs b/src/Bet.Extensions.HealthChecks/CertificateCheck/SslCertificateHealthCheck.cs new file mode 100644 index 0000000..0644b8b --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/CertificateCheck/SslCertificateHealthCheck.cs @@ -0,0 +1,39 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Bet.Extensions.HealthChecks.CertificateCheck +{ + public class SslCertificateHealthCheck : IHealthCheck + { + private readonly IHttpClientFactory _httpClientFactory; + + public SslCertificateHealthCheck(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var checkName = context.Registration.Name; + var httpClient = _httpClientFactory.CreateClient(checkName); + + using (var response = await httpClient.GetAsync("/", cancellationToken)) + { + response.EnsureSuccessStatusCode(); + } + + return new HealthCheckResult(HealthStatus.Healthy, $"{checkName}-{httpClient.BaseAddress}"); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } + } +} diff --git a/src/Bet.Extensions.HealthChecks/HealthChecksBuilderExtensions.cs b/src/Bet.Extensions.HealthChecks/HealthChecksBuilderExtensions.cs index 0a20d62..a75e020 100644 --- a/src/Bet.Extensions.HealthChecks/HealthChecksBuilderExtensions.cs +++ b/src/Bet.Extensions.HealthChecks/HealthChecksBuilderExtensions.cs @@ -1,7 +1,15 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Security; +using Bet.Extensions.HealthChecks.AzureBlobStorage; +using Bet.Extensions.HealthChecks.CertificateCheck; +using Bet.Extensions.HealthChecks.MemoryCheck; using Bet.Extensions.HealthChecks.PingCheck; using Bet.Extensions.HealthChecks.Publishers; +using Bet.Extensions.HealthChecks.SigtermCheck; +using Bet.Extensions.HealthChecks.UriCheck; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; @@ -13,6 +21,205 @@ namespace Microsoft.Extensions.DependencyInjection /// public static class HealthChecksBuilderExtensions { + /// + /// Adds Azure Storage Health Check. + /// + /// The hc builder. + /// The name of the hc. + /// The name of the container to be checked. + /// The setup action for the hc. + /// The failure status to be returned. The default is 'HealthStatus.Degraded'. + /// The optional tags. + /// + public static IHealthChecksBuilder AddAzureBlobStorageCheck( + this IHealthChecksBuilder builder, + string name, + string containerName, + Action setup, + HealthStatus? failureStatus = default, + IEnumerable tags = default) + { + var options = new StorageAccountOptions(); + setup?.Invoke(options); + + builder.Services.AddOptions(name) + .Configure((opt) => + { + opt.ConnectionString = options.ConnectionString; + opt.ContainerName = containerName; + opt.Name = options.Name; + opt.Token = options.Token; + }); + + builder.AddCheck(name, failureStatus ?? HealthStatus.Degraded, tags); + + return builder; + } + + /// + /// Adds SSL Website Certificate check. + /// + /// The hc builder. + /// The name of the hc. + /// The website base url. + /// The number of days before SSL expires. + /// The failure status to be returned. The default is 'HealthStatus.Degraded'. + /// The optional tags. + /// + public static IHealthChecksBuilder AddSslCertificateCheck( + this IHealthChecksBuilder builder, + string name, + string baseUrl, + int beforeSslExpriesDays = 30, + HealthStatus? failureStatus = default, + IEnumerable tags = default) + { + builder.Services.AddHttpClient(name, (sp, config) => + { + config.BaseAddress = new Uri(baseUrl); + }).ConfigurePrimaryHttpMessageHandler(sp => + { + var handler = new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + + ServerCertificateCustomValidationCallback = (httpRequestMessage, certificate, cetChain, sslPolicyErrors) => + { + var expirationDate = DateTime.Parse(certificate.GetExpirationDateString()); + if (expirationDate - DateTime.Today < TimeSpan.FromDays(beforeSslExpriesDays)) + { + throw new Exception("Time to renew the certificate!"); + } + + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + else + { + throw new Exception("Cert policy errors: " + sslPolicyErrors.ToString()); + } + } + }; + return handler; + }); + + builder.AddCheck(name, failureStatus ?? HealthStatus.Degraded, tags); + return builder; + } + + /// + /// Add SIGTERM Healcheck that provides notification for orchestrator with unhealthy status once the application begins to shut down. + /// + /// The . + /// The name of the HealthCheck. + /// The The type should be reported when the health check fails. Optional. If then. + /// A list of tags that can be used to filter sets of health checks. Optional. + /// + public static IHealthChecksBuilder AddSigtermCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus = null, + IEnumerable tags = default) + { + builder.AddCheck(name, failureStatus, tags); + + return builder; + } + + /// + /// Add a HealthCHeck for a single or many s instances. + /// + /// + /// + /// + /// + /// + /// + public static IHealthChecksBuilder AddUriHealthCheck( + this IHealthChecksBuilder builder, + string name, + Action uriOptions, + HealthStatus? failureStatus = default, + IEnumerable tags = default) + { + // TODO ability to add custom httpclient for the calls. + var client = builder.Services.AddHttpClient(name, (sp, config) => + { + // config.Timeout = TimeSpan.FromSeconds(10); + }); + + var check = new UriHealthCheckBuilder(builder.Services, name); + + check.Add(uriOptions); + + builder.AddCheck(name, failureStatus, tags); + + return builder; + } + + /// + /// Add a HealthCHeck for a single or many s instances. + /// + /// The . + /// The name of the HealthCheck. + /// The delegate. + /// The The type should be reported when the health check fails. Optional. If then. + /// A list of tags that can be used to filter sets of health checks. Optional. + /// + public static IHealthChecksBuilder AddUriHealthCheck( + this IHealthChecksBuilder builder, + string name, + Action registration, + HealthStatus? failureStatus = default, + IEnumerable tags = default) + { + // TODO ability to add custom httpclient for the calls. + var client = builder.Services.AddHttpClient(name, (sp, config) => + { + // config.Timeout = TimeSpan.FromSeconds(10); + }); + + var check = new UriHealthCheckBuilder(builder.Services, name); + + registration(check); + + builder.AddCheck(name, failureStatus, tags); + + return builder; + } + + /// + /// Add a HealthCheck for Garbage Collection Memory check. + /// + /// The . + /// The name of the HealthCheck. + /// The The type should be reported when the health check fails. Optional. If then. + /// A list of tags that can be used to filter sets of health checks. Optional. + /// The Threshold in bytes. The default is 1073741824 bytes or 1Gig. + /// + public static IHealthChecksBuilder AddMemoryHealthCheck( + this IHealthChecksBuilder builder, + string name = "memory", + 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; + } + /// /// Adds Ping HealthCheck. /// diff --git a/src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryCheckOptions.cs b/src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryCheckOptions.cs new file mode 100644 index 0000000..c954dc4 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryCheckOptions.cs @@ -0,0 +1,10 @@ +namespace Bet.Extensions.HealthChecks.MemoryCheck +{ + public class MemoryCheckOptions + { + /// + /// 1073741824 Bytes Failure threshold (in bytes) or 1024MB, 1Gig. + /// + public long Threshold { get; set; } = 1024L * 1024L * 1024L; + } +} diff --git a/src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryHealthCheck.cs b/src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryHealthCheck.cs new file mode 100644 index 0000000..ed1c751 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/MemoryCheck/MemoryHealthCheck.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace Bet.Extensions.HealthChecks.MemoryCheck +{ + /// + /// HealthCheck for Garbage Collector memory usage for the application. + /// + public class MemoryHealthCheck : IHealthCheck + { + private readonly IOptionsMonitor _options; + + public MemoryHealthCheck(IOptionsMonitor options) + { + _options = options; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var options = _options.Get(context.Registration.Name); + + // Include GC information in the reported diagnostics. + var allocated = GC.GetTotalMemory(forceFullCollection: false); + var data = new Dictionary() + { + { "AllocatedBytes", allocated }, + { "Gen0Collections", GC.CollectionCount(0) }, + { "Gen1Collections", GC.CollectionCount(1) }, + { "Gen2Collections", GC.CollectionCount(2) }, + }; + + var status = (allocated < options.Threshold) ? HealthStatus.Healthy : context.Registration.FailureStatus; + + return Task.FromResult(new HealthCheckResult( + status, + description: "Reports degraded status if allocated bytes " + $">= {options.Threshold} bytes.", + exception: null, + data: data)); + } + } +} diff --git a/src/Bet.Extensions.HealthChecks/README.md b/src/Bet.Extensions.HealthChecks/README.md new file mode 100644 index 0000000..85e4ce7 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/README.md @@ -0,0 +1,62 @@ +# Bet.Extensions.HealthChecks + +[![Build status](https://ci.appveyor.com/api/projects/status/fo9rakj7s7uhs3ij?svg=true)](https://ci.appveyor.com/project/kdcllc/bet-aspnetcore) +[![NuGet](https://img.shields.io/nuget/v/Bet.Extensions.HealthChecks.svg)](https://www.nuget.org/packages?q=Bet.Extensions.HealthChecks) + +The collection of the HealthChecks functionality for `IHost` that can be used within the `Worker` in Kubernetes. + +## Usage + +1. Add `HealthCheck` configuration in `ConfigureServices`. + +```csharp + + services.AddHealthChecks() + // validates ssl certificate + .AddSslCertificateCheck("localhost", "https://localhost:5001") + .AddSslCertificateCheck("kdcllc", "https://kingdavidconsulting.com") + // Checks specific url + .AddUriHealthCheck("200_check", builder => + { + builder.Add(option => + { + option.AddUri("https://httpstat.us/200") + .UseExpectedHttpCode(HttpStatusCode.OK); + }); + + builder.Add(option => + { + option.AddUri("https://httpstat.us/203") + .UseExpectedHttpCode(HttpStatusCode.NonAuthoritativeInformation); + }); + }) + .AddUriHealthCheck("ms_check", uriOptions: (options) => + { + options.AddUri("https://httpstat.us/503").UseExpectedHttpCode(503); + }) + // used with kubernetes + .AddSigtermCheck("Sigterm_shutdown_check") + .AddSocketListener(8080); +``` + +## Helm configuration for Kubernetes + +```yaml + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: health + containerPort: 8080 + protocol: TCP + livenessProbe: + tcpSocket: + path: / + port: health + readinessProbe: + tcpSocket: + path: / + port: health +``` \ No newline at end of file diff --git a/src/Bet.Extensions.HealthChecks/SigtermCheck/SigtermHealthCheck.cs b/src/Bet.Extensions.HealthChecks/SigtermCheck/SigtermHealthCheck.cs new file mode 100644 index 0000000..b3b82be --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/SigtermCheck/SigtermHealthCheck.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Bet.Extensions.HealthChecks.SigtermCheck +{ + public class SigtermHealthCheck : IHealthCheck + { +#if NETCOREAPP3_0 || NETSTANDARD2_1 + private readonly IHostApplicationLifetime _applicationLifetime; + + public SigtermHealthCheck(IHostApplicationLifetime applicationLifetime) + { + _applicationLifetime = applicationLifetime; + } +#else + private readonly IApplicationLifetime _applicationLifetime; + + public SigtermHealthCheck(IApplicationLifetime applicationLifetime) + { + _applicationLifetime = applicationLifetime; + } +#endif + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + if (_applicationLifetime.ApplicationStopping.IsCancellationRequested) + { + return Task.FromResult(new HealthCheckResult( + context.Registration.FailureStatus, + description: "IApplicationLifetime.ApplicationStopping was requested")); + } + + return Task.FromResult(HealthCheckResult.Healthy()); + } + } +} diff --git a/src/Bet.Extensions.HealthChecks/UriCheck/IUriHealthCheckBuilder.cs b/src/Bet.Extensions.HealthChecks/UriCheck/IUriHealthCheckBuilder.cs new file mode 100644 index 0000000..636aa50 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/UriCheck/IUriHealthCheckBuilder.cs @@ -0,0 +1,20 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; + +namespace Bet.Extensions.HealthChecks.UriCheck +{ + public interface IUriHealthCheckBuilder + { + /// + /// Name of the Uris Check Group. + /// + string CheckName { get; } + + IServiceCollection Services { get; } + + IUriHealthCheckBuilder Add(Action action); + + IUriHealthCheckBuilder Add(UriOptionsSetup optionsSetup); + } +} diff --git a/src/Bet.Extensions.HealthChecks/UriCheck/IUriOptionsSetup.cs b/src/Bet.Extensions.HealthChecks/UriCheck/IUriOptionsSetup.cs new file mode 100644 index 0000000..9b02260 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/UriCheck/IUriOptionsSetup.cs @@ -0,0 +1,64 @@ +using System; +using System.Net; +using System.Net.Http; + +namespace Bet.Extensions.HealthChecks.UriCheck +{ + public interface IUriOptionsSetup + { + /// + /// The Uri to perform the healthcheck on. + /// + /// + /// + IUriOptionsSetup AddUri(Uri uri); + + /// + /// The Uri as string to perform the healthcheck on. + /// + /// + /// + IUriOptionsSetup AddUri(string uri); + + /// + /// Override default timeout of 10 seconds. + /// + /// + /// + IUriOptionsSetup UseTimeout(TimeSpan timeout); + + /// + /// Override default HttpMethod.Get. + /// + /// + /// + IUriOptionsSetup UseHttpMethod(HttpMethod httpMethod); + + /// + /// See more for Status Codes information: . + /// summary> + IUriOptionsSetup UseExpectHttpCodes(int min, int max); + + /// + /// Single HttpStatus code as value. + /// + /// + /// + IUriOptionsSetup UseExpectedHttpCode(HttpStatusCode statusCode); + + /// + /// Single HttpStatus code as value. + /// + /// + /// + IUriOptionsSetup UseExpectedHttpCode(int statusCode); + + /// + /// Add Http Custom Header to the request. It can be Authorization. + /// + /// + /// + /// + IUriOptionsSetup AddCustomHeader(string name, string value); + } +} diff --git a/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheck.cs b/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheck.cs new file mode 100644 index 0000000..8d4ed16 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheck.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace Bet.Extensions.HealthChecks.UriCheck +{ + public class UriHealthCheck : IHealthCheck + { + private readonly IOptionsMonitor _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly Dictionary _data = new Dictionary(); + private bool _isHealthy = true; + + public UriHealthCheck( + IOptionsMonitor options, + IHttpClientFactory httpClientFactory) + { + _options = options; + _httpClientFactory = httpClientFactory; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var index = 0; + try + { + var checkName = context.Registration.Name; + + var options = _options.Get(checkName); + + foreach (var option in options.UriOptions) + { + var httpClient = _httpClientFactory.CreateClient(checkName); + + using (var requestMessage = new HttpRequestMessage(option.HttpMethod, option.Uri)) + { + foreach (var (name, value) in option.Headers) + { + requestMessage.Headers.Add(name, value); + } + + using (var timeoutSource = new CancellationTokenSource(option.Timeout)) + using (var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutSource.Token, cancellationToken)) + { + var response = await httpClient.SendAsync(requestMessage, linkedSource.Token); + + if (!((int)response.StatusCode >= option.ExpectedHttpCodes.min && (int)response.StatusCode <= option.ExpectedHttpCodes.max)) + { + _isHealthy = false; + + var errorMessage = $"Discover endpoint #{index} is not responding with code in {option.ExpectedHttpCodes.min}...{option.ExpectedHttpCodes.max} range, the current status is {response.StatusCode}."; + + _data.Add(option.Uri.ToString(), errorMessage); + } + else + { + var message = $"Discovered endpoint #{index} is responding with {response.StatusCode}."; + _data.Add(option.Uri.ToString(), message); + } + + ++index; + } + } + } + + var status = _isHealthy ? HealthStatus.Healthy : context.Registration.FailureStatus; + + return new HealthCheckResult( + status, + description: $"Reports degraded status if one of {index} failed", + exception: null, + data: _data); + } + catch (Exception ex) + { + // TODO: not expose all of the exception details for security reasons. + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } + } +} diff --git a/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckBuilder.cs b/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckBuilder.cs new file mode 100644 index 0000000..813ed3d --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckBuilder.cs @@ -0,0 +1,54 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; + +namespace Bet.Extensions.HealthChecks.UriCheck +{ + public class UriHealthCheckBuilder : IUriHealthCheckBuilder + { + public UriHealthCheckBuilder( + IServiceCollection services, + string checkName) + { + Services = services; + CheckName = checkName; + } + + public IServiceCollection Services { get; } + + public string CheckName { get; } + + public IUriHealthCheckBuilder Add(Action action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var option = new UriOptionsSetup(); + + action(option); + + Services.Configure(CheckName, options => + { + options.UriOptions.Add(option); + }); + + return this; + } + + public IUriHealthCheckBuilder Add(UriOptionsSetup optionsSetup) + { + if (optionsSetup == null) + { + throw new ArgumentNullException(nameof(optionsSetup)); + } + + Services.Configure(CheckName, option => + { + option.UriOptions.Add(optionsSetup); + }); + return this; + } + } +} diff --git a/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckOptions.cs b/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckOptions.cs new file mode 100644 index 0000000..1989c72 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/UriCheck/UriHealthCheckOptions.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Bet.Extensions.HealthChecks.UriCheck +{ + public class UriHealthCheckOptions + { + public ICollection UriOptions { get; } = new List(); + } +} diff --git a/src/Bet.Extensions.HealthChecks/UriCheck/UriOptionsSetup.cs b/src/Bet.Extensions.HealthChecks/UriCheck/UriOptionsSetup.cs new file mode 100644 index 0000000..2dab452 --- /dev/null +++ b/src/Bet.Extensions.HealthChecks/UriCheck/UriOptionsSetup.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; + +namespace Bet.Extensions.HealthChecks.UriCheck +{ + /// + /// The class to provide configuration for the healthcheck request. + /// + public class UriOptionsSetup : IUriOptionsSetup + { + private readonly List<(string name, string value)> _headers = new List<(string name, string value)>(); + + /// + /// Initializes a new instance of the class. + /// Default values for. + /// + /// + public UriOptionsSetup(Uri uri = default) + { + Uri = uri; + + // success codes + ExpectedHttpCodes = (200, 226); + + HttpMethod = HttpMethod.Get; + + Timeout = TimeSpan.FromSeconds(10); + } + + public HttpMethod HttpMethod { get; private set; } + + public TimeSpan Timeout { get; private set; } + + public (int min, int max) ExpectedHttpCodes { get; private set; } + + public Uri Uri { get; private set; } + + internal IEnumerable<(string name, string value)> Headers => _headers; + + /// + public IUriOptionsSetup AddUri(Uri uri) + { + Uri = uri; + return this; + } + + /// + public IUriOptionsSetup AddUri(string uri) + { + Uri = new Uri(uri); + + return this; + } + + /// + public IUriOptionsSetup UseTimeout(TimeSpan timeout) + { + Timeout = timeout; + return this; + } + + /// + public IUriOptionsSetup UseHttpMethod(HttpMethod httpMethod) + { + HttpMethod = httpMethod; + + return this; + } + + /// + public IUriOptionsSetup UseExpectHttpCodes(int min, int max) + { + ExpectedHttpCodes = (min, max); + + return this; + } + + /// + public IUriOptionsSetup UseExpectedHttpCode(HttpStatusCode statusCode) + { + var code = (int)statusCode; + + ExpectedHttpCodes = (code, code); + + return this; + } + + /// + public IUriOptionsSetup UseExpectedHttpCode(int statusCode) + { + ExpectedHttpCodes = (statusCode, statusCode); + + return this; + } + + public IUriOptionsSetup AddCustomHeader(string name, string value) + { + _headers.Add((name, value)); + + return this; + } + } +} diff --git a/test/Bet.Extensions.UnitTest/HealthChecks/HealthCheckPublishersTests.cs b/test/Bet.Extensions.UnitTest/HealthChecks/HealthCheckPublishersTests.cs index d657b48..283d050 100644 --- a/test/Bet.Extensions.UnitTest/HealthChecks/HealthCheckPublishersTests.cs +++ b/test/Bet.Extensions.UnitTest/HealthChecks/HealthCheckPublishersTests.cs @@ -57,7 +57,7 @@ public async Task SocketHealthPublisher_Should_Start_TcpListener() await publisher.PublishAsync(report, CancellationToken.None); - var response = TcpClientResponse(port); + var response = TcpClientResponse(port, "ping"); Assert.Equal("ping", response); Assert.Equal(HealthStatus.Healthy, report.Status); } @@ -95,8 +95,11 @@ public async Task SocketHealthPublisher_Should_Execute() await Task.Delay(TimeSpan.FromSeconds(2)); - var response = TcpClientResponse(port); - Assert.Equal("ping", response); + var response1 = TcpClientResponse(port, "ping"); + Assert.Equal("ping", response1); + + var response2 = TcpClientResponse(port, "ping2"); + Assert.Equal("ping2", response2); await host.StopAsync(); } @@ -137,11 +140,11 @@ public async Task LoggingHealthCheckPublisher_Should_Execute() await host.StopAsync(); } - private static string TcpClientResponse(int port) + private static string TcpClientResponse(int port, string message) { using var client = new TcpClient("localhost", port); using var stream = client.GetStream(); - var data = Encoding.ASCII.GetBytes("ping"); + var data = Encoding.ASCII.GetBytes(message); stream.Write(data, 0, data.Length); data = new byte[256];