Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
- Reduce allocations in `Utility.IsAzureMonitorLoggingEnabled` (#11323)
- Update PowerShell worker to [4.0.4581](https://github.com/Azure/azure-functions-powershell-worker/releases/tag/v4.0.4581)
- Bug fix that fails in-flight invocations when a worker channel shuts down (#11159)
- Adds WebHost and ScriptHost health checks. (#11341, #11183, #11178, #11173, #11161)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Azure.WebJobs.Script.Description;
using Microsoft.Azure.WebJobs.Script.Management.Models;
using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
Expand All @@ -28,7 +27,7 @@ namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers
{
/// <summary>
/// Controller responsible for administrative and management operations on functions
/// example retrieving a list of functions, invoking a function, creating a function, etc
/// example retrieving a list of functions, invoking a function, creating a function, etc.
/// </summary>
public class FunctionsController : Controller
{
Expand Down
1 change: 0 additions & 1 deletion src/WebJobs.Script.WebHost/Controllers/HostController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
using Microsoft.Azure.WebJobs.Script.Diagnostics;
using Microsoft.Azure.WebJobs.Script.ExtensionBundle;
using Microsoft.Azure.WebJobs.Script.Scale;
using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
using Microsoft.Azure.WebJobs.Script.WebHost.Management;
using Microsoft.Azure.WebJobs.Script.WebHost.Models;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies;

namespace Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.HealthChecks
{
public sealed class HealthCheckAuthMiddleware(
RequestDelegate next, IPolicyEvaluator policy, IAuthorizationPolicyProvider provider)
{
private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next));
private readonly IPolicyEvaluator _policy = policy ?? throw new ArgumentNullException(nameof(policy));
private readonly IAuthorizationPolicyProvider _provider = provider ?? throw new ArgumentNullException(nameof(provider));

public async Task InvokeAsync(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);

AuthorizationPolicy policy = await _provider.GetPolicyAsync(PolicyNames.AdminAuthLevel)
.ConfigureAwait(false);

AuthenticateResult authentication = await _policy.AuthenticateAsync(policy, context)
.ConfigureAwait(false);

if (!authentication.Succeeded)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}

PolicyAuthorizationResult authorization = await _policy.AuthorizeAsync(
policy, authentication, context, null).ConfigureAwait(false);

if (!authorization.Succeeded)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: new lines (same for lines 30-31)

Suggested change
if (!authorization.Succeeded)
if (!authorization.Succeeded)

{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}

await _next(context);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@

using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Script.Extensions;
using Microsoft.Azure.WebJobs.Script.WebHost.Configuration;
using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.HealthChecks;
using Microsoft.Azure.WebJobs.Script.WebHost.Middleware;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -41,6 +44,10 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder
builder.UseMiddleware<SystemTraceMiddleware>();
builder.UseMiddleware<HandleCancellationMiddleware>();
builder.UseMiddleware<HostnameFixupMiddleware>();

// Health is registered early in the pipeline to ensure it can avoid failures from the rest of the pipeline.
builder.UseHealthChecks();

if (environment.IsAnyLinuxConsumption() || environment.IsAnyKubernetesEnvironment())
{
builder.UseMiddleware<EnvironmentReadyCheckMiddleware>();
Expand Down Expand Up @@ -116,5 +123,40 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder

return builder;
}

private static void UseHealthChecks(this IApplicationBuilder app)
{
// '/runtime' is a reserved API path. We use that to avoid conflict with any customer function routes.
const string healthPrefix = "/runtime/health";
static bool Predicate(HttpContext context)
{
return context.Request.Path.StartsWithSegments(healthPrefix);
}

app.MapWhen(Predicate, app =>
{
app.UseMiddleware<HealthCheckAuthMiddleware>();

// This supports the ?wait={seconds} query string.
app.UseMiddleware<HealthCheckWaitMiddleware>();

app.UseHealthChecks(healthPrefix, new HealthCheckOptions
{
ResponseWriter = HealthCheckResponseWriter.WriteResponseAsync,
});

app.UseHealthChecks($"{healthPrefix}/live", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("az.functions.liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteResponseAsync,
});

app.UseHealthChecks($"{healthPrefix}/ready", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("az.functions.readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteResponseAsync,
});
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public static IHealthChecksBuilder AddWebJobsScriptHealthChecks(this IHealthChec
ArgumentNullException.ThrowIfNull(builder);
builder
.AddWebHostHealthCheck()
.AddScriptHostHealthCheck();
.AddScriptHostHealthCheck()
.AddTelemetryPublisher(HealthCheckTags.Liveness, HealthCheckTags.Readiness);
return builder;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Diagnostics.Metrics;
using Microsoft.Azure.WebJobs.Script.Metrics;

namespace Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks
{
Expand All @@ -22,7 +23,7 @@ public HealthCheckMetrics(IMeterFactory meterFactory)
// We don't dispose the meter because IMeterFactory handles that
// An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912
// Related documentation: https://github.com/dotnet/docs/pull/37170
Meter meter = meterFactory.Create("Microsoft.Azure.WebJobs.Script");
Meter meter = meterFactory.Create(HostMetrics.FaasMeterName, HostMetrics.FaasMeterVersion);
#pragma warning restore CA2000 // Dispose objects before losing scope

HealthCheckReport = HealthCheckMetricsGeneration.CreateHealthCheckReportHistogram(meter);
Expand Down
4 changes: 3 additions & 1 deletion src/WebJobs.Script/Metrics/HostMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public class HostMetrics : IHostMetrics
// FaaS Metrics
public const string FaasInvokeDuration = "faas.invoke_duration";

public static readonly string FaasMeterVersion = typeof(HostMetrics).Assembly.GetName().Version?.ToString();

private Counter<long> _appFailureCount;
private Counter<long> _startedInvocationCount;
private Histogram<double> _faasInvokeDuration;
Expand Down Expand Up @@ -65,7 +67,7 @@ public HostMetrics(IMeterFactory meterFactory, IEnvironment environment, ILogger
_appFailureCount = meter.CreateCounter<long>(AppFailureCount, "numeric", "Number of times the host has failed to start.");
_startedInvocationCount = meter.CreateCounter<long>(StartedInvocationCount, "numeric", "Number of function invocations that have started.");

var faasMeter = meterFactory.Create(new MeterOptions(FaasMeterName));
var faasMeter = meterFactory.Create(new MeterOptions(FaasMeterName) { Version = FaasMeterVersion });
_faasInvokeDuration = faasMeter.CreateHistogram<double>(
name: FaasInvokeDuration,
unit: "s",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,35 @@ public async Task HostPing_Succeeds(string method)
Assert.Equal("no-store, no-cache", cacheHeader);
}

[Theory]
[InlineData("/runtime/health")]
[InlineData("/runtime/health/live")]
[InlineData("/runtime/health/ready")]
public async Task HealthCheck_AdminToken_Succeeds(string uri)
{
// token specified as bearer token
HttpRequestMessage request = new(HttpMethod.Get, uri);
string token = _fixture.Host.GenerateAdminJwtToken();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

string body = await response.Content.ReadAsStringAsync();
Assert.Equal("{\"status\":\"Healthy\"}", body);
}

[Theory]
[InlineData("/runtime/health")]
[InlineData("/runtime/health/live")]
[InlineData("/runtime/health/ready")]
public async Task HealthCheck_NoAdminToken_Fail(string uri)
{
// token specified as bearer token
HttpRequestMessage request = new(HttpMethod.Get, uri);
HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task InstallExtensionsEnsureOldPathReturns404()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ public void AddScriptHostHealthCheck_ThrowsOnNullBuilder()
}

[Fact]
public void AddWebJobsScriptHealthChecks_RegistersBothHealthChecks()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any tests that verify the new http routes E2E, e.g. similar to the host API tests we have in SamplesEndToEndTests_CSharp

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some tests there.

public void AddWebJobsScriptHealthChecks_RegistersExpectedServices()
{
// arrange
ServiceCollection services = new();
Mock<IHealthChecksBuilder> builder = new(MockBehavior.Strict);
builder.Setup(b => b.Services).Returns(services);
builder.Setup(b => b.Add(It.IsAny<HealthCheckRegistration>())).Returns(builder.Object);

// act
Expand All @@ -67,7 +69,10 @@ public void AddWebJobsScriptHealthChecks_RegistersBothHealthChecks()
builder.Verify(b => b.Add(IsRegistration<ScriptHostHealthCheck>(
HealthCheckNames.ScriptHostLifeCycle, HealthCheckTags.Readiness)),
Times.Once);
builder.Verify(b => b.Services, Times.AtLeastOnce);
builder.VerifyNoOtherCalls();

VerifyPublishers(services, null, HealthCheckTags.Liveness, HealthCheckTags.Readiness);
}

[Fact]
Expand Down Expand Up @@ -216,17 +221,7 @@ public void AddTelemetryPublisher_RegistersExpected(string[] tags, string[] expe
builder.AddTelemetryPublisher(tags);

// assert
services.Where(x => x.ServiceType == typeof(IHealthCheckPublisher)).Should().HaveCount(expected.Length)
.And.AllSatisfy(x => x.Lifetime.Should().Be(ServiceLifetime.Singleton));

ServiceProvider provider = services.BuildServiceProvider();
IEnumerable<IHealthCheckPublisher> publishers = provider.GetServices<IHealthCheckPublisher>();

publishers.Should().HaveCount(expected.Length);
foreach (string tag in expected)
{
publishers.Should().ContainSingle(p => VerifyPublisher(p, tag));
}
VerifyPublishers(services, expected);
}

private static HealthCheckRegistration IsRegistration<T>(string name, string tag)
Expand All @@ -248,6 +243,21 @@ static bool IsType(HealthCheckRegistration registration)
});
}

private static void VerifyPublishers(IServiceCollection services, params string[] tags)
{
services.Where(x => x.ServiceType == typeof(IHealthCheckPublisher)).Should().HaveCount(tags.Length)
.And.AllSatisfy(x => x.Lifetime.Should().Be(ServiceLifetime.Singleton));

ServiceProvider provider = services.BuildServiceProvider();
IEnumerable<IHealthCheckPublisher> publishers = provider.GetServices<IHealthCheckPublisher>();

publishers.Should().HaveCount(tags.Length);
foreach (string tag in tags)
{
publishers.Should().ContainSingle(p => VerifyPublisher(p, tag));
}
}

private static bool VerifyPublisher(IHealthCheckPublisher publisher, string tag)
{
return publisher is TelemetryHealthCheckPublisher telemetryPublisher
Expand Down