diff --git a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs index 336fcbc925..e65a12444f 100644 --- a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs +++ b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs @@ -170,6 +170,12 @@ static bool Predicate(HttpContext context) Predicate = r => r.Tags.Contains(HealthCheckTags.Readiness), ResponseWriter = HealthCheckResponseWriter.WriteResponseAsync, }); + + app.UseHealthChecks($"{healthPrefix}/connectivity", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains(HealthCheckTags.Connectivity), + ResponseWriter = HealthCheckResponseWriter.WriteResponseAsync, + }); }); } } diff --git a/src/WebJobs.Script/Diagnostics/HealthChecks/ConnectivityHealthCheck.cs b/src/WebJobs.Script/Diagnostics/HealthChecks/ConnectivityHealthCheck.cs new file mode 100644 index 0000000000..7bf0b1df3a --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/HealthChecks/ConnectivityHealthCheck.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Description; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks +{ + /// + /// DRAFT host adapter (Flex Consumption Network Troubleshooter) that surfaces extension-provided + /// implementations as a single connectivity health check. + /// It enumerates the app's triggers, matches each to a registered validator by trigger type, + /// invokes it with the binding's connection + settings, and aggregates the results. + /// + /// + /// This keeps the Microsoft.Extensions.Diagnostics.HealthChecks dependency in the host (here) + /// and out of every extension: extensions register a plain , + /// and this one adapter turns them into a connectivity-tagged . + /// + internal sealed class ConnectivityHealthCheck : IHealthCheck + { + private readonly IEnumerable _validators; + private readonly IFunctionMetadataManager _metadataManager; + + public ConnectivityHealthCheck( + IEnumerable validators, + IFunctionMetadataManager metadataManager) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _metadataManager = metadataManager ?? throw new ArgumentNullException(nameof(metadataManager)); + } + + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + Dictionary data = new(); + bool anyUnhealthy = false; + + foreach (FunctionMetadata function in _metadataManager.GetFunctionMetadata()) + { + BindingMetadata trigger = function.Trigger; + if (trigger is null || string.IsNullOrEmpty(trigger.Connection)) + { + // Not a remote trigger (e.g. HTTP/timer) or nothing to probe. + continue; + } + + IConnectivityValidator validator = _validators.FirstOrDefault( + v => string.Equals(v.TriggerType, trigger.Type, StringComparison.OrdinalIgnoreCase)); + if (validator is null) + { + // No validator shipped for this trigger's extension (bundle-versioned coverage). + continue; + } + + ConnectivityContext probeContext = new( + trigger.Type, + trigger.Connection, + new Dictionary(trigger.Properties)); + + ConnectivityResult result = await validator + .ValidateAsync(probeContext, cancellationToken) + .ConfigureAwait(false); + + data[function.Name] = result.IsHealthy ? "Healthy" : $"Unhealthy: {result.Details}"; + anyUnhealthy |= !result.IsHealthy; + } + + return anyUnhealthy + ? HealthCheckResult.Unhealthy("One or more trigger dependencies are unreachable.", data: data) + : HealthCheckResult.Healthy(data: data); + } + } +} diff --git a/src/WebJobs.Script/Diagnostics/HealthChecks/DnsConnectivityHealthCheck.cs b/src/WebJobs.Script/Diagnostics/HealthChecks/DnsConnectivityHealthCheck.cs new file mode 100644 index 0000000000..4f7d628a4c --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/HealthChecks/DnsConnectivityHealthCheck.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks +{ + /// + /// SDK-free connectivity check that verifies DNS resolution for a configured set of hosts. + /// + /// + /// Prototype for the Network Troubleshooter (DRAFT). Hosts are read from the + /// NETWORK_CHECK_DNS_HOSTS setting (comma-separated); the production check will derive + /// targets from trigger binding metadata. Registered on the WebHost scope so it runs in + /// validation mode as well as on a normal worker. + /// + internal sealed class DnsConnectivityHealthCheck : IHealthCheck + { + private const string DnsHostsSetting = "NETWORK_CHECK_DNS_HOSTS"; + + private readonly IConfiguration _configuration; + + public DnsConnectivityHealthCheck(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + string hostsSetting = _configuration[DnsHostsSetting]; + if (string.IsNullOrWhiteSpace(hostsSetting)) + { + return HealthCheckResult.Healthy("No DNS hosts configured; connectivity check skipped."); + } + + foreach (string host in hostsSetting.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + try + { + await Dns.GetHostEntryAsync(host, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (!ex.IsFatal()) + { + HealthCheckData data = new() { Area = HealthCheckData.Areas.Connectivity }; + data.SetExceptionDetails(ex); + return HealthCheckResult.Unhealthy($"DNS resolution failed for '{host}'.", ex, data); + } + } + + return HealthCheckResult.Healthy(); + } + } +} diff --git a/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckExtensions.cs b/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckExtensions.cs index 15abb4ffc2..14b1c2666e 100644 --- a/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckExtensions.cs +++ b/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckExtensions.cs @@ -29,6 +29,7 @@ public static IHealthChecksBuilder AddWebJobsScriptHealthChecks(this IHealthChec .AddWebHostHealthCheck() .AddScriptHostHealthCheck() .AddWebJobsStorageHealthCheck() + .AddDnsConnectivityHealthCheck() .AddTelemetryPublisher(HealthCheckTags.Liveness, HealthCheckTags.Readiness) .UseDynamicHealthCheckService(); return builder; @@ -146,6 +147,21 @@ public static IHealthChecksBuilder AddWebJobsStorageHealthCheck(this IHealthChec return builder; } + /// + /// Adds an SDK-free DNS connectivity check (Network Troubleshooter prototype). + /// + /// The builder to register health checks with. + /// The original builder, for call chaining. + public static IHealthChecksBuilder AddDnsConnectivityHealthCheck(this IHealthChecksBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.AddCheck( + HealthCheckNames.DnsConnectivity, + tags: [HealthCheckTags.Connectivity], + timeout: TimeSpan.FromSeconds(10)); + return builder; + } + /// /// Filters a health report to include only specified entries. /// diff --git a/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckNames.cs b/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckNames.cs index 2a5022eed6..3290b2fbbb 100644 --- a/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckNames.cs +++ b/src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckNames.cs @@ -24,5 +24,10 @@ internal static class HealthCheckNames /// The 'azure.functions.webjobs.storage' check monitors connectivity to the WebJobs storage account. /// public const string WebJobsStorage = Prefix + "webjobs.storage"; + + /// + /// The 'azure.functions.connectivity.dns' check verifies DNS resolution for configured hosts (Network Troubleshooter prototype). + /// + public const string DnsConnectivity = Prefix + "connectivity.dns"; } } diff --git a/src/WebJobs.Script/Diagnostics/HealthChecks/IConnectivityValidator.cs b/src/WebJobs.Script/Diagnostics/HealthChecks/IConnectivityValidator.cs new file mode 100644 index 0000000000..0654da9c98 --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/HealthChecks/IConnectivityValidator.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks +{ + /// + /// DRAFT abstraction for the Flex Consumption Network Troubleshooter. An extension implements this to + /// validate connectivity to its trigger dependency using its own SDK and connection resolution, + /// without taking a dependency on Microsoft.Extensions.Diagnostics.HealthChecks. The host + /// adapts all registered validators into a single connectivity . + /// + /// + /// Final home is Microsoft.Azure.WebJobs (the WebJobs SDK), which both the host and the + /// extensions already reference — so extensions add no new package dependency. Defined here in the + /// host only to prototype the mechanism end-to-end. + /// + internal interface IConnectivityValidator + { + /// Gets the trigger binding type this validator handles, e.g. "eventHubTrigger". + string TriggerType { get; } + + /// Performs a non-mutating connectivity + auth probe for a single trigger binding. + Task ValidateAsync(ConnectivityContext context, CancellationToken cancellationToken); + } + + /// The target of a connectivity probe, supplied by the host from a trigger binding. + internal sealed class ConnectivityContext + { + public ConnectivityContext(string triggerType, string connection, IReadOnlyDictionary properties) + { + TriggerType = triggerType; + Connection = connection; + Properties = properties; + } + + /// Gets the trigger binding type, e.g. "eventHubTrigger". + public string TriggerType { get; } + + /// Gets the connection setting name from the binding; the extension resolves it. + public string Connection { get; } + + /// Gets the raw binding settings (e.g. eventHubName, queueName); the extension reads what it needs. + public IReadOnlyDictionary Properties { get; } + } + + /// The result of a connectivity probe. + internal sealed class ConnectivityResult + { + private ConnectivityResult(bool isHealthy, string details) + { + IsHealthy = isHealthy; + Details = details; + } + + public bool IsHealthy { get; } + + public string Details { get; } + + public static ConnectivityResult Healthy(string details = null) => new(true, details); + + public static ConnectivityResult Unhealthy(string details) => new(false, details); + } +} diff --git a/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/ConnectivityHealthCheckTests.cs b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/ConnectivityHealthCheckTests.cs new file mode 100644 index 0000000000..f293e054c3 --- /dev/null +++ b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/ConnectivityHealthCheckTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Azure.WebJobs.Script.Description; +using Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Diagnostics.HealthChecks +{ + // Demonstrates the extension-owned connectivity abstraction end-to-end (Network Troubleshooter): + // an extension registers a plain IConnectivityValidator (no HealthChecks dependency); the host + // adapter enumerates the app's triggers, matches the validator by trigger type, and hands it the + // binding's connection + settings — turning it into a connectivity health check result. + public class ConnectivityHealthCheckTests + { + [Fact] + public async Task CheckHealthAsync_InvokesMatchingValidator_WithBindingConnectionAndSettings() + { + // Arrange: an app with one Event Hub trigger. + BindingMetadata trigger = new() + { + Name = "events", + Type = "eventHubTrigger", + Connection = "MyEventHubConnection", + Direction = BindingDirection.In, + }; + trigger.Properties["eventHubName"] = "my-hub"; + FunctionMetadata function = new() { Name = "ProcessEvents" }; + function.Bindings.Add(trigger); + + Mock metadataManager = new(); + metadataManager + .Setup(m => m.GetFunctionMetadata(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ImmutableArray.Create(function)); + + RecordingValidator validator = new("eventHubTrigger"); + ConnectivityHealthCheck check = new(new[] { validator }, metadataManager.Object); + + // Act + HealthCheckResult result = await check.CheckHealthAsync(new HealthCheckContext()); + + // Assert: the adapter found the Event Hub trigger, matched the validator by trigger type, + // and handed it the binding's connection + settings. + result.Status.Should().Be(HealthStatus.Healthy); + validator.Invoked.Should().BeTrue(); + validator.LastContext.Connection.Should().Be("MyEventHubConnection"); + validator.LastContext.Properties["eventHubName"].Should().Be("my-hub"); + } + + [Fact] + public async Task CheckHealthAsync_UnhealthyValidator_ReportsUnhealthy() + { + BindingMetadata trigger = new() + { + Name = "events", + Type = "eventHubTrigger", + Connection = "MyEventHubConnection", + Direction = BindingDirection.In, + }; + FunctionMetadata function = new() { Name = "ProcessEvents" }; + function.Bindings.Add(trigger); + + Mock metadataManager = new(); + metadataManager + .Setup(m => m.GetFunctionMetadata(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ImmutableArray.Create(function)); + + RecordingValidator validator = new("eventHubTrigger") + { + Result = ConnectivityResult.Unhealthy("Auth failed"), + }; + ConnectivityHealthCheck check = new(new[] { validator }, metadataManager.Object); + + HealthCheckResult result = await check.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public async Task CheckHealthAsync_NoValidatorForTrigger_SkipsAndReportsHealthy() + { + BindingMetadata trigger = new() + { + Name = "events", + Type = "serviceBusTrigger", + Connection = "MyServiceBusConnection", + Direction = BindingDirection.In, + }; + FunctionMetadata function = new() { Name = "ProcessMessages" }; + function.Bindings.Add(trigger); + + Mock metadataManager = new(); + metadataManager + .Setup(m => m.GetFunctionMetadata(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ImmutableArray.Create(function)); + + // Only an Event Hubs validator is registered; the Service Bus trigger has no validator yet. + RecordingValidator validator = new("eventHubTrigger"); + ConnectivityHealthCheck check = new(new[] { validator }, metadataManager.Object); + + HealthCheckResult result = await check.CheckHealthAsync(new HealthCheckContext()); + + validator.Invoked.Should().BeFalse(); + result.Status.Should().Be(HealthStatus.Healthy); + } + + private sealed class RecordingValidator : IConnectivityValidator + { + public RecordingValidator(string triggerType) => TriggerType = triggerType; + + public string TriggerType { get; } + + public bool Invoked { get; private set; } + + public ConnectivityContext LastContext { get; private set; } + + public ConnectivityResult Result { get; set; } = ConnectivityResult.Healthy(); + + public Task ValidateAsync(ConnectivityContext context, CancellationToken cancellationToken) + { + Invoked = true; + LastContext = context; + return Task.FromResult(Result); + } + } + } +} diff --git a/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/DynamicHealthCheckServiceTests.cs b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/DynamicHealthCheckServiceTests.cs index 04d236199f..6e23af85a2 100644 --- a/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/DynamicHealthCheckServiceTests.cs +++ b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/DynamicHealthCheckServiceTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -197,5 +198,59 @@ private void SetupScriptHostHealthService(HealthReport report) _mockServices.Setup(s => s.GetService(typeof(HealthCheckService))) .Returns(mockScriptHostHealthService.Object); } + + // Demonstrates the extension-owned connectivity model (Network Troubleshooter): an extension + // registers a connectivity IHealthCheck in the ScriptHost (JobHost) DI scope—exactly as its + // IWebJobsStartup.Configure would via services.AddHealthChecks().AddCheck(...)—and the check + // flows through the WebHost/ScriptHost merge and the /admin/health/connectivity tag filter, + // with no host dependency on the extension's SDK. + [Fact] + public async Task CheckHealthAsync_ExtensionRegistersConnectivityCheckInScriptHostScope_FlowsThroughConnectivityFilter() + { + // WebHost scope: a host-owned connectivity check plus a non-connectivity (liveness) check. + ServiceCollection webHostServices = new(); + webHostServices.AddLogging(); + webHostServices.AddHealthChecks() + .AddCheck("host.dns.connectivity", tags: [HealthCheckTags.Connectivity]) + .AddCheck("host.liveness", tags: [HealthCheckTags.Liveness]); + using ServiceProvider webHostProvider = webHostServices.BuildServiceProvider(); + HealthCheckService webHostHealth = webHostProvider.GetRequiredService(); + + // ScriptHost scope: an extension registers its own connectivity check. + ServiceCollection scriptHostServices = new(); + scriptHostServices.AddLogging(); + scriptHostServices.AddHealthChecks() + .AddCheck("ext.eventhubs.connectivity", tags: [HealthCheckTags.Connectivity]); + using ServiceProvider scriptHostProvider = scriptHostServices.BuildServiceProvider(); + _mockManager.Setup(m => m.Services).Returns(scriptHostProvider); + + DynamicHealthCheckService service = new(webHostHealth, _mockManager.Object, _logger); + + // Query with the same predicate the /admin/health/connectivity route uses. + HealthReport result = await service.CheckHealthAsync(r => r.Tags.Contains(HealthCheckTags.Connectivity)); + + // Both connectivity checks flow through the merge; the non-connectivity check is filtered out. + result.Entries.Should().ContainKey("host.dns.connectivity"); + result.Entries.Should().ContainKey("ext.eventhubs.connectivity"); + result.Entries.Should().NotContainKey("host.liveness"); + } + + private sealed class FakeHostConnectivityCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + => Task.FromResult(HealthCheckResult.Healthy()); + } + + private sealed class FakeExtensionConnectivityCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + => Task.FromResult(HealthCheckResult.Healthy()); + } + + private sealed class FakeLivenessCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + => Task.FromResult(HealthCheckResult.Healthy()); + } } } \ No newline at end of file