diff --git a/src/WebJobs.Script.WebHost/Controllers/KeysController.cs b/src/WebJobs.Script.WebHost/Controllers/KeysController.cs index fc927d9bdd..099b135087 100644 --- a/src/WebJobs.Script.WebHost/Controllers/KeysController.cs +++ b/src/WebJobs.Script.WebHost/Controllers/KeysController.cs @@ -69,7 +69,7 @@ public async Task Get() // Extensions that are webhook providers create their default system keys // as part of host initialization (when those keys aren't already present). // So we must delay key retrieval until host initialization is complete. - await _hostManager.DelayUntilHostReady(); + await _hostManager.DelayUntilHostReadyAsync(); } Dictionary keys = await GetHostSecretsByScope(hostKeyScope); diff --git a/src/WebJobs.Script.WebHost/Diagnostics/HealthChecks/HealthCheckResponseWriter.cs b/src/WebJobs.Script.WebHost/Diagnostics/HealthChecks/HealthCheckResponseWriter.cs new file mode 100644 index 0000000000..72e40e8a0f --- /dev/null +++ b/src/WebJobs.Script.WebHost/Diagnostics/HealthChecks/HealthCheckResponseWriter.cs @@ -0,0 +1,43 @@ +// 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.Text.Json; +using System.Threading.Tasks; +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.HealthChecks +{ + public class HealthCheckResponseWriter + { + public static Task WriteResponseAsync(HttpContext httpContext, HealthReport report) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(report); + + // We will write a detailed report if ?expand=true is present. + if (httpContext.Request.Query.TryGetValue("expand", out StringValues value) + && bool.TryParse(value, out bool expand) && expand) + { + return UIResponseWriter.WriteHealthCheckUIResponse(httpContext, report); + } + + return WriteMinimalResponseAsync(httpContext, report); + } + + private static Task WriteMinimalResponseAsync(HttpContext httpContext, HealthReport report) + { + MinimalResponse body = new(report.Status); + return JsonSerializer.SerializeAsync( + httpContext.Response.Body, body, JsonSerializerOptionsProvider.Options, httpContext.RequestAborted); + } + + internal readonly struct MinimalResponse(HealthStatus status) + { + public HealthStatus Status { get; } = status; + } + } +} diff --git a/src/WebJobs.Script.WebHost/Diagnostics/HealthChecks/HealthCheckWaitMiddleware.cs b/src/WebJobs.Script.WebHost/Diagnostics/HealthChecks/HealthCheckWaitMiddleware.cs new file mode 100644 index 0000000000..07d5675571 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Diagnostics/HealthChecks/HealthCheckWaitMiddleware.cs @@ -0,0 +1,42 @@ +// 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.Http; +using Microsoft.Azure.WebJobs.Script.WebHost.Models; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.HealthChecks +{ + public sealed class HealthCheckWaitMiddleware(RequestDelegate next, IScriptHostManager manager) + { + private const int MaxWaitSeconds = 60; + private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next)); + private readonly IScriptHostManager _manager = manager ?? throw new ArgumentNullException(nameof(manager)); + + public async Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // If specified, the ?wait={seconds} query param will wait for an + // active script host for that duration. This is to avoid excessive polling + // when waiting for the initial readiness probe. + if (context.Request.Query.TryGetValue("wait", out StringValues wait)) + { + if (!int.TryParse(wait.ToString(), out int waitSeconds) || waitSeconds < 0) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync( + ErrorResponse.BadArgument("'wait' query param must be a positive integer", $"wait={wait}")); + return; + } + + waitSeconds = Math.Min(waitSeconds, MaxWaitSeconds); + await _manager.DelayUntilHostReadyAsync(waitSeconds).WaitAsync(context.RequestAborted); + } + + await _next(context); + } + } +} diff --git a/src/WebJobs.Script.WebHost/Extensions/HttpContextExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/HttpContextExtensions.cs index 6895067d8b..71443d79e4 100644 --- a/src/WebJobs.Script.WebHost/Extensions/HttpContextExtensions.cs +++ b/src/WebJobs.Script.WebHost/Extensions/HttpContextExtensions.cs @@ -23,7 +23,7 @@ public static async Task WaitForRunningHostAsync(this HttpContext httpContext, I // If the host is not ready, we'll wait a bit for it to initialize. // This might happen if http requests come in while the host is starting // up for the first time, or if it is restarting. - bool hostReady = await hostManager.DelayUntilHostReady(timeoutSeconds, pollingIntervalMilliseconds); + bool hostReady = await hostManager.DelayUntilHostReadyAsync(timeoutSeconds, pollingIntervalMilliseconds); if (!hostReady) { diff --git a/src/WebJobs.Script.WebHost/Extensions/ScriptHostManagerExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/ScriptHostManagerExtensions.cs index 0770bbc058..86889e1446 100644 --- a/src/WebJobs.Script.WebHost/Extensions/ScriptHostManagerExtensions.cs +++ b/src/WebJobs.Script.WebHost/Extensions/ScriptHostManagerExtensions.cs @@ -7,7 +7,7 @@ namespace Microsoft.Azure.WebJobs.Script { public static class ScriptHostManagerExtensions { - public static async Task DelayUntilHostReady(this IScriptHostManager hostManager, int timeoutSeconds = ScriptConstants.HostTimeoutSeconds, int pollingIntervalMilliseconds = ScriptConstants.HostPollingIntervalMilliseconds) + public static async Task DelayUntilHostReadyAsync(this IScriptHostManager hostManager, int timeoutSeconds = ScriptConstants.HostTimeoutSeconds, int pollingIntervalMilliseconds = ScriptConstants.HostPollingIntervalMilliseconds) { if (HostIsInitialized(hostManager)) { diff --git a/src/WebJobs.Script.WebHost/Middleware/HostAvailabilityCheckMiddleware.cs b/src/WebJobs.Script.WebHost/Middleware/HostAvailabilityCheckMiddleware.cs index 2dc4917c55..4ef5095da7 100644 --- a/src/WebJobs.Script.WebHost/Middleware/HostAvailabilityCheckMiddleware.cs +++ b/src/WebJobs.Script.WebHost/Middleware/HostAvailabilityCheckMiddleware.cs @@ -54,7 +54,7 @@ private static async Task InvokeAwaitingHost(HttpContext context, RequestDelegat { Logger.InitiatingHostAvailabilityCheck(logger); - bool hostReady = await scriptHostManager.DelayUntilHostReady(); + bool hostReady = await scriptHostManager.DelayUntilHostReadyAsync(); if (!hostReady) { Logger.HostUnavailableAfterCheck(logger); diff --git a/src/WebJobs.Script.WebHost/Middleware/HostWarmupMiddleware.cs b/src/WebJobs.Script.WebHost/Middleware/HostWarmupMiddleware.cs index a9e4e2ca10..22ca847c17 100644 --- a/src/WebJobs.Script.WebHost/Middleware/HostWarmupMiddleware.cs +++ b/src/WebJobs.Script.WebHost/Middleware/HostWarmupMiddleware.cs @@ -161,7 +161,7 @@ public async Task HostWarmupAsync(HttpRequest request) await _hostManager.RestartHostAsync("Host warmup call requested a restart.", CancellationToken.None); // This call is here for sanity, but we should be fully initialized. - await _hostManager.DelayUntilHostReady(); + await _hostManager.DelayUntilHostReadyAsync(); } } diff --git a/src/WebJobs.Script.WebHost/Models/ApiErrorModel.cs b/src/WebJobs.Script.WebHost/Models/ApiErrorModel.cs deleted file mode 100644 index 45359586cd..0000000000 --- a/src/WebJobs.Script.WebHost/Models/ApiErrorModel.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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.Net; -using Newtonsoft.Json; - -namespace Microsoft.Azure.WebJobs.Script.WebHost.Models -{ - public class ApiErrorModel - { - public ApiErrorModel(HttpStatusCode status) - : this() - { - StatusCode = status; - } - - public ApiErrorModel() - { - Id = Guid.NewGuid().ToString(); - } - - [JsonProperty("id")] - public string Id { get; set; } - - [JsonProperty("requestId")] - public string RequestId { get; set; } - - [JsonProperty("statusCode")] - public HttpStatusCode StatusCode { get; set; } - - [JsonProperty("errorCode")] - public int ErrorCode { get; set; } - - [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] - public string Message { get; set; } - - [JsonProperty("errorDetails", NullValueHandling = NullValueHandling.Ignore)] - public string ErrorDetails { get; set; } - - [JsonProperty("arguments", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Arguments { get; set; } - } -} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Models/ErrorResponse.cs b/src/WebJobs.Script.WebHost/Models/ErrorResponse.cs new file mode 100644 index 0000000000..8f8704255d --- /dev/null +++ b/src/WebJobs.Script.WebHost/Models/ErrorResponse.cs @@ -0,0 +1,63 @@ +// 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.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Models +{ + /// + /// Represents an error response. + /// See https://github.com/Azure/azure-resource-manager-rpc/blob/master/v1.0/common-api-details.md#error-response-content. + /// + /// + /// The error code. This is NOT the HTTP status code. + /// Unlocalized string which can be used to programmatically identify the error. + /// The code should be Pascal-cased, and should serve to uniquely identify a particular class of error, + /// for example "BadArgument". + /// + /// + /// The error message. Describes the error in detail and provides debugging information. + /// If Accept-Language is set in the request, it must be localized to that language. + /// ] + public record ErrorResponse( + [property: JsonProperty("code")][property: JsonPropertyName("code")] string Code, + [property: JsonProperty("message")][property: JsonPropertyName("message")] string Message) + { + /// + /// Gets the target of the particular error. For example, the name of the property in error. + /// + [JsonProperty("target")] + [JsonPropertyName("target")] + public string Target { get; init; } + + /// + /// Gets the details of this error. + /// + [JsonProperty("details")] + [JsonPropertyName("details")] + public IEnumerable Details { get; init; } = []; + + /// + /// Gets the additional information for this error. + /// + [JsonProperty("additionalInfo")] + [JsonPropertyName("additionalInfo")] + public IEnumerable AdditionalInfo { get; init; } = []; + + public static ErrorResponse BadArgument(string message, string target = null) + { + return new("BadArgument", message) { Target = target }; + } + } + + /// + /// Represents additional information for an error. + /// + /// The type of additional information. + /// The additional error information. + public record ErrorAdditionalInfo( + [property: JsonProperty("type")][property: JsonPropertyName("type")] string Type, + [property: JsonProperty("info")][property: JsonPropertyName("info")] object Info); +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Standby/StandbyManager.cs b/src/WebJobs.Script.WebHost/Standby/StandbyManager.cs index 9da44cedd5..5be160c80c 100644 --- a/src/WebJobs.Script.WebHost/Standby/StandbyManager.cs +++ b/src/WebJobs.Script.WebHost/Standby/StandbyManager.cs @@ -124,7 +124,7 @@ public async Task SpecializeHostCoreAsync() using (_metricsLogger.LatencyEvent(MetricEventNames.SpecializationDelayUntilHostReady)) { - await _scriptHostManager.DelayUntilHostReady(); + await _scriptHostManager.DelayUntilHostReadyAsync(); } } diff --git a/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj b/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj index ec08a85df2..b7b916393a 100644 --- a/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj +++ b/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj @@ -50,6 +50,7 @@ + diff --git a/src/WebJobs.Script/JsonSerializerOptionsProvider.cs b/src/WebJobs.Script/JsonSerializerOptionsProvider.cs new file mode 100644 index 0000000000..85035cde1a --- /dev/null +++ b/src/WebJobs.Script/JsonSerializerOptionsProvider.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Azure.WebJobs.Script +{ + /// + /// Provides constants related to JSON serialization options used. + /// + public static class JsonSerializerOptionsProvider + { + /// + /// Shared Json serializer with the following settings: + /// - AllowTrailingCommas: true + /// - PropertyNamingPolicy: CamelCase + /// - DefaultIgnoreCondition: WhenWritingNull. + /// + public static readonly JsonSerializerOptions Options = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + options.Converters.Add(new JsonStringEnumConverter()); + + return options; + } + } +} diff --git a/src/WebJobs.Script/runtimeassemblies.json b/src/WebJobs.Script/runtimeassemblies.json index 93326e206d..2f02d7b06b 100644 --- a/src/WebJobs.Script/runtimeassemblies.json +++ b/src/WebJobs.Script/runtimeassemblies.json @@ -67,6 +67,14 @@ "name": "Grpc.Net.Common", "resolutionPolicy": "private" }, + { + "name": "HealthChecks.UI.Client", + "resolutionPolicy": "private" + }, + { + "name": "HealthChecks.UI.Core", + "resolutionPolicy": "private" + }, { "name": "Microsoft.AI.DependencyCollector", "resolutionPolicy": "private" diff --git a/test/WebJobs.Script.Tests.Integration/Controllers/ControllerScenarioTestFixture.cs b/test/WebJobs.Script.Tests.Integration/Controllers/ControllerScenarioTestFixture.cs index b3824a593a..58d987ecdf 100644 --- a/test/WebJobs.Script.Tests.Integration/Controllers/ControllerScenarioTestFixture.cs +++ b/test/WebJobs.Script.Tests.Integration/Controllers/ControllerScenarioTestFixture.cs @@ -76,7 +76,7 @@ public virtual async Task InitializeAsync() HttpClient.BaseAddress = new Uri("https://localhost/"); var manager = HttpServer.Host.Services.GetService(); - await manager.DelayUntilHostReady(); + await manager.DelayUntilHostReadyAsync(); } public Task DisposeAsync() diff --git a/test/WebJobs.Script.Tests.Shared/TestHelpers.cs b/test/WebJobs.Script.Tests.Shared/TestHelpers.cs index e14261b301..81fa14f128 100644 --- a/test/WebJobs.Script.Tests.Shared/TestHelpers.cs +++ b/test/WebJobs.Script.Tests.Shared/TestHelpers.cs @@ -35,6 +35,28 @@ public static partial class TestHelpers private const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static readonly Random Random = new Random(); + /// + /// Helper method to inline an action delegate. + /// + /// The action. + /// The provided action. + /// + /// This is intended to be used with a fluent assertion. + /// Act(() => { }).Should().Something();. + /// + public static Action Act(Action act) => act; + + /// + /// Helper method to inline an action delegate. + /// + /// The action. + /// The provided action. + /// + /// This is intended to be used with a fluent assertion. + /// Act(() => { }).Should().Something();. + /// + public static Func Act(Func act) => act; + public static Task WaitOneAsync(this WaitHandle waitHandle) { ArgumentNullException.ThrowIfNull(waitHandle); diff --git a/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/HealthCheckResponseWriterTests.cs b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/HealthCheckResponseWriterTests.cs new file mode 100644 index 0000000000..791336d5ff --- /dev/null +++ b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/HealthCheckResponseWriterTests.cs @@ -0,0 +1,117 @@ +// 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.IO; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Diagnostics.HealthChecks +{ + public class HealthCheckResponseWriterTests + { + [Fact] + public async Task WriteResponseAsync_NullHttpContext_Throws() + { + HealthReport report = new HealthReport(null, HealthStatus.Healthy, TimeSpan.Zero); + await TestHelpers.Act(() => + HealthCheckResponseWriter.WriteResponseAsync(null, report)) + .Should().ThrowAsync().WithParameterName("httpContext"); + } + + [Fact] + public async Task WriteResponseAsync_NullReport_Throws() + { + await TestHelpers.Act(() => + HealthCheckResponseWriter.WriteResponseAsync(Mock.Of(), null)) + .Should().ThrowAsync().WithParameterName("report"); + } + + [Fact] + public async Task WriteResponseAsync_ExpandTrue_CallsUIResponseWriter() + { + // Arrange + DefaultHttpContext context = new(); + context.Request.QueryString = new QueryString("?expand=true"); + using MemoryStream stream = new(); + context.Response.Body = stream; + + Dictionary checks = new() + { + ["test.check.1"] = new HealthReportEntry( + HealthStatus.Healthy, null, TimeSpan.FromMilliseconds(10), null, null, ["test.tag.1"]), + ["test.check.2"] = new HealthReportEntry( + HealthStatus.Unhealthy, + "Test unhealthy check", + TimeSpan.FromSeconds(1), + new Exception("Error! Error!"), + new Dictionary() { ["test.data.1"] = "test value 1" }, + ["test.tag.1", "test.tag.2"]), + }; + + HealthReport report = new(checks, TimeSpan.FromSeconds(1)); + + // Act + await HealthCheckResponseWriter.WriteResponseAsync(context, report); + + // Assert + JsonObject expected = new() + { + ["status"] = "Unhealthy", + ["totalDuration"] = "00:00:01", + ["entries"] = new JsonObject + { + ["test.check.1"] = new JsonObject + { + ["data"] = new JsonObject(), + ["duration"] = "00:00:00.0100000", + ["status"] = "Healthy", + ["tags"] = new JsonArray { "test.tag.1" } + }, + ["test.check.2"] = new JsonObject + { + ["data"] = new JsonObject { ["test.data.1"] = "test value 1" }, + ["description"] = "Test unhealthy check", + ["duration"] = "00:00:01", + ["exception"] = "Error! Error!", + ["status"] = "Unhealthy", + ["tags"] = new JsonArray { "test.tag.1", "test.tag.2" } + } + } + }; + + stream.Position = 0; + string actual = await new StreamReader(stream).ReadToEndAsync(); + string expectedJson = expected.ToJsonString(); + + actual.Should().Be(expectedJson); + } + + [Fact] + public async Task WriteResponseAsync_ExpandNotTrue_WritesMinimalResponse() + { + // Arrange + DefaultHttpContext context = new(); + context.Request.QueryString = new QueryString(string.Empty); // no expand + HealthReport report = new(null, HealthStatus.Healthy, TimeSpan.Zero); + using MemoryStream stream = new(); + context.Response.Body = stream; + + // Act + await HealthCheckResponseWriter.WriteResponseAsync(context, report); + + // Assert + stream.Position = 0; + string actual = await new StreamReader(stream).ReadToEndAsync(); + string expected = "{\"status\":\"Healthy\"}"; + actual.Should().Be(expected); + } + } +} diff --git a/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/HealthCheckWaitMiddlewareTests.cs b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/HealthCheckWaitMiddlewareTests.cs new file mode 100644 index 0000000000..94a2e2a4d5 --- /dev/null +++ b/test/WebJobs.Script.Tests/Diagnostics/HealthChecks/HealthCheckWaitMiddlewareTests.cs @@ -0,0 +1,118 @@ +// 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.IO; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.HealthChecks; +using Microsoft.Azure.WebJobs.Script.WebHost.Models; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Diagnostics.HealthChecks +{ + public class HealthCheckWaitMiddlewareTests + { + [Fact] + public void Constructor_NullNext_ThrowsArgumentNullException() + { + TestHelpers.Act(() => new HealthCheckWaitMiddleware(null, Mock.Of())) + .Should().Throw().WithParameterName("next"); + } + + [Fact] + public void Constructor_NullManager_ThrowsArgumentNullException() + { + TestHelpers.Act(() => new HealthCheckWaitMiddleware(Mock.Of(), null)) + .Should().Throw().WithParameterName("manager"); + } + + [Theory] + [InlineData(null)] + [InlineData("?not_a_known_query_value=true")] + public async Task InvokeAsync_NoWaitQuery_Continues(string query) + { + // arrange + Mock next = new(); + Mock manager = new(MockBehavior.Strict); + HttpContext context = CreateContext(query, null); + HealthCheckWaitMiddleware middleware = new(next.Object, manager.Object); + + // act + await middleware.InvokeAsync(context); + + // assert + next.Verify(m => m(context), Times.Once); + next.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData("10s")] + [InlineData("true")] + [InlineData("-10")] + public async Task InvokeAsync_InvalidWaitQuery_BadRequest(string wait) + { + // arrange + using MemoryStream stream = new(); + Mock next = new(); + Mock manager = new(MockBehavior.Strict); + HttpContext context = CreateContext($"?wait={wait}", stream); + HealthCheckWaitMiddleware middleware = new(next.Object, manager.Object); + + // act + await middleware.InvokeAsync(context); + stream.Position = 0; + + // assert + next.Verify(m => m(context), Times.Never); + next.VerifyNoOtherCalls(); + + context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + ErrorResponse error = await JsonSerializer.DeserializeAsync(stream); + error.Code.Should().Be("BadArgument"); + } + + [Fact] + public async Task InvokeAsync_ValidWaitQuery_CallsDelayAndNext() + { + // arrange + // each failed loop is 3 calls, plus 1 for the final call. + int neededCalls = (Random.Shared.Next(1, 10) * 3) + 1; + int calls = 0; + TaskCompletionSource tcs = new(); + Mock next = new(); + Mock manager = new(MockBehavior.Strict); + manager.Setup(m => m.State).Returns(() => + { + // The check calls this 2 times per loop. + return calls++ < neededCalls ? ScriptHostState.Starting : ScriptHostState.Running; + }); + + HttpContext context = CreateContext($"?wait=5", null); + var middleware = new HealthCheckWaitMiddleware(next.Object, manager.Object); + + // act + await middleware.InvokeAsync(context); + + // assert + manager.Verify(m => m.State, Times.Exactly(neededCalls + 3)); + next.Verify(m => m(context), Times.Once); + next.VerifyNoOtherCalls(); + } + + private static DefaultHttpContext CreateContext(string query, Stream body) + { + DefaultHttpContext context = new(); + context.Request.QueryString = new(query); + if (body is not null) + { + context.Response.Body = body; + } + + return context; + } + } +} \ No newline at end of file diff --git a/test/WebJobs.Script.Tests/Microsoft.Azure.WebJobs.Script.WebHost.deps.json b/test/WebJobs.Script.Tests/Microsoft.Azure.WebJobs.Script.WebHost.deps.json index 9e43ab19c6..c6badf3f96 100644 --- a/test/WebJobs.Script.Tests/Microsoft.Azure.WebJobs.Script.WebHost.deps.json +++ b/test/WebJobs.Script.Tests/Microsoft.Azure.WebJobs.Script.WebHost.deps.json @@ -6,8 +6,9 @@ "compilationOptions": {}, "targets": { ".NETCoreApp,Version=v8.0": { - "Microsoft.Azure.WebJobs.Script.WebHost/4.1043.100-pr.25404.3": { + "Microsoft.Azure.WebJobs.Script.WebHost/4.1043.100-pr.25425.3": { "dependencies": { + "AspNetCore.HealthChecks.UI.Client": "9.0.0", "Azure.Data.Tables": "12.8.3", "Azure.Identity": "1.14.2", "Azure.Security.KeyVault.Secrets": "4.6.0", @@ -19,11 +20,11 @@ "Microsoft.AspNet.WebApi.Client": "5.2.8", "Microsoft.AspNetCore.Authentication.JwtBearer": "6.0.0", "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "8.0.1", - "Microsoft.Azure.AppService.Middleware.Functions": "1.5.5", + "Microsoft.Azure.AppService.Middleware.Functions": "1.5.7", "Microsoft.Azure.AppService.Proxy.Client": "2.3.20240307.67", "Microsoft.Azure.Functions.DotNetIsolatedNativeHost": "1.0.12", "Microsoft.Azure.Functions.JavaWorker": "2.19.2", - "Microsoft.Azure.Functions.NodeJsWorker": "3.10.1", + "Microsoft.Azure.Functions.NodeJsWorker": "3.11.0", "Microsoft.Azure.Functions.Platform.Metrics.LinuxConsumption": "1.0.5", "Microsoft.Azure.Functions.PowerShellWorker.PS7.0": "4.0.3148", "Microsoft.Azure.Functions.PowerShellWorker.PS7.2": "4.0.4025", @@ -32,8 +33,8 @@ "Microsoft.Azure.Storage.File": "11.1.7", "Microsoft.Azure.WebJobs": "3.0.41", "Microsoft.Azure.WebJobs.Host.Storage": "5.0.1", - "Microsoft.Azure.WebJobs.Script": "4.1043.100-pr.25404.3", - "Microsoft.Azure.WebJobs.Script.Grpc": "4.1043.100-pr.25404.3", + "Microsoft.Azure.WebJobs.Script": "4.1043.100-pr.25425.3", + "Microsoft.Azure.WebJobs.Script.Grpc": "4.1043.100-pr.25425.3", "Microsoft.Azure.WebSites.DataProtection": "2.1.91-alpha", "Microsoft.IdentityModel.Protocols.OpenIdConnect": "6.35.0", "Microsoft.IdentityModel.Tokens": "6.35.0", @@ -51,6 +52,28 @@ "Microsoft.Azure.WebJobs.Script.WebHost.dll": {} } }, + "AspNetCore.HealthChecks.UI.Client/9.0.0": { + "dependencies": { + "AspNetCore.HealthChecks.UI.Core": "9.0.0" + }, + "runtime": { + "lib/net8.0/HealthChecks.UI.Client.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.0.0" + } + } + }, + "AspNetCore.HealthChecks.UI.Core/9.0.0": { + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11" + }, + "runtime": { + "lib/net8.0/HealthChecks.UI.Core.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.0.0" + } + } + }, "Autofac/4.6.2": { "dependencies": { "NETStandard.Library": "2.0.1", @@ -710,7 +733,7 @@ "System.Text.Encodings.Web": "8.0.0" } }, - "Microsoft.Azure.AppService.Middleware/1.5.5": { + "Microsoft.Azure.AppService.Middleware/1.5.7": { "dependencies": { "Microsoft.Extensions.Configuration": "9.0.0", "Microsoft.Extensions.Logging.Console": "8.0.0", @@ -718,27 +741,27 @@ }, "runtime": { "lib/netstandard2.0/Microsoft.Azure.AppService.Middleware.dll": { - "assemblyVersion": "1.5.5.0", - "fileVersion": "1.5.5.0" + "assemblyVersion": "1.5.7.0", + "fileVersion": "1.5.7.0" } } }, - "Microsoft.Azure.AppService.Middleware.Functions/1.5.5": { + "Microsoft.Azure.AppService.Middleware.Functions/1.5.7": { "dependencies": { - "Microsoft.Azure.AppService.Middleware": "1.5.5", - "Microsoft.Azure.AppService.Middleware.Modules": "1.5.5", - "Microsoft.Azure.AppService.Middleware.NetCore": "1.5.5" + "Microsoft.Azure.AppService.Middleware": "1.5.7", + "Microsoft.Azure.AppService.Middleware.Modules": "1.5.7", + "Microsoft.Azure.AppService.Middleware.NetCore": "1.5.7" }, "runtime": { "lib/netstandard2.0/Microsoft.Azure.AppService.Middleware.Functions.dll": { - "assemblyVersion": "1.5.5.0", - "fileVersion": "1.5.5.0" + "assemblyVersion": "1.5.7.0", + "fileVersion": "1.5.7.0" } } }, - "Microsoft.Azure.AppService.Middleware.Modules/1.5.5": { + "Microsoft.Azure.AppService.Middleware.Modules/1.5.7": { "dependencies": { - "Microsoft.Azure.AppService.Middleware": "1.5.5", + "Microsoft.Azure.AppService.Middleware": "1.5.7", "Microsoft.Extensions.Caching.Memory": "5.0.0", "Microsoft.Extensions.Configuration": "9.0.0", "Microsoft.Extensions.Logging": "9.0.0", @@ -750,16 +773,16 @@ }, "runtime": { "lib/netstandard2.0/Microsoft.Azure.AppService.Middleware.Modules.dll": { - "assemblyVersion": "1.5.5.0", - "fileVersion": "1.5.5.0" + "assemblyVersion": "1.5.7.0", + "fileVersion": "1.5.7.0" } } }, - "Microsoft.Azure.AppService.Middleware.NetCore/1.5.5": { + "Microsoft.Azure.AppService.Middleware.NetCore/1.5.7": { "dependencies": { "Microsoft.AspNetCore.Http.Abstractions": "2.2.0", - "Microsoft.Azure.AppService.Middleware": "1.5.5", - "Microsoft.Azure.AppService.Middleware.Modules": "1.5.5", + "Microsoft.Azure.AppService.Middleware": "1.5.7", + "Microsoft.Azure.AppService.Middleware.Modules": "1.5.7", "Microsoft.Extensions.Caching.Memory": "5.0.0", "Microsoft.Extensions.Configuration": "9.0.0", "Microsoft.Extensions.Logging": "9.0.0", @@ -769,8 +792,8 @@ }, "runtime": { "lib/netstandard2.0/Microsoft.Azure.AppService.Middleware.NetCore.dll": { - "assemblyVersion": "1.5.5.0", - "fileVersion": "1.5.5.0" + "assemblyVersion": "1.5.7.0", + "fileVersion": "1.5.7.0" } } }, @@ -821,7 +844,7 @@ }, "Microsoft.Azure.Functions.DotNetIsolatedNativeHost/1.0.12": {}, "Microsoft.Azure.Functions.JavaWorker/2.19.2": {}, - "Microsoft.Azure.Functions.NodeJsWorker/3.10.1": {}, + "Microsoft.Azure.Functions.NodeJsWorker/3.11.0": {}, "Microsoft.Azure.Functions.Platform.Metrics.LinuxConsumption/1.0.5": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.0", @@ -1013,7 +1036,7 @@ "runtime": { "lib/netstandard2.0/Microsoft.Azure.WebJobs.Script.Abstractions.dll": { "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.21962.0" + "fileVersion": "1.0.22000.0" } } }, @@ -1419,6 +1442,28 @@ } } }, + "Microsoft.Extensions.Diagnostics.HealthChecks/8.0.11": { + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.6", + "Microsoft.Extensions.Logging.Abstractions": "9.0.6", + "Microsoft.Extensions.Options": "9.0.6" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Diagnostics.HealthChecks.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1124.52116" + } + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/8.0.11": { + "runtime": { + "lib/net8.0/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1124.52116" + } + } + }, "Microsoft.Extensions.FileProviders.Abstractions/9.0.6": { "dependencies": { "Microsoft.Extensions.Primitives": "9.0.6" @@ -2954,7 +2999,7 @@ } } }, - "Microsoft.Azure.WebJobs.Script/4.1043.100-pr.25404.3": { + "Microsoft.Azure.WebJobs.Script/4.1043.100-pr.25425.3": { "dependencies": { "Azure.Core": "1.47.1", "Azure.Data.Tables": "12.8.3", @@ -3008,7 +3053,7 @@ "Microsoft.Azure.WebJobs.Script.dll": {} } }, - "Microsoft.Azure.WebJobs.Script.Grpc/4.1043.100-pr.25404.3": { + "Microsoft.Azure.WebJobs.Script.Grpc/4.1043.100-pr.25425.3": { "dependencies": { "Grpc.AspNetCore": "2.55.0", "Microsoft.ApplicationInsights": "2.22.0", @@ -3017,7 +3062,7 @@ "Microsoft.ApplicationInsights.WindowsServer": "2.22.0", "Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel": "2.22.0", "Microsoft.Azure.WebJobs.Rpc.Core": "3.0.37", - "Microsoft.Azure.WebJobs.Script": "4.1043.100-pr.25404.3", + "Microsoft.Azure.WebJobs.Script": "4.1043.100-pr.25425.3", "System.IO.FileSystem.Primitives": "4.3.0", "System.Threading.Channels": "8.0.0" }, @@ -3029,7 +3074,7 @@ "runtime": { "Microsoft.Azure.WebJobs.Script.dll": { "assemblyVersion": "4.1043.0.0", - "fileVersion": "4.1043.100.25404" + "fileVersion": "4.1043.100.25425" } } }, @@ -3037,18 +3082,32 @@ "runtime": { "Microsoft.Azure.WebJobs.Script.Grpc.dll": { "assemblyVersion": "4.1043.0.0", - "fileVersion": "4.1043.100.25404" + "fileVersion": "4.1043.100.25425" } } } } }, "libraries": { - "Microsoft.Azure.WebJobs.Script.WebHost/4.1043.100-pr.25404.3": { + "Microsoft.Azure.WebJobs.Script.WebHost/4.1043.100-pr.25425.3": { "type": "project", "serviceable": false, "sha512": "" }, + "AspNetCore.HealthChecks.UI.Client/9.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-1Ub3Wvvbz7CMuFNWgLEc9qqQibiMoovDML/WHrwr5J83RPgtI20giCR92s/ipLgu7IIuqw+W/y7WpIeHqAICxg==", + "path": "aspnetcore.healthchecks.ui.client/9.0.0", + "hashPath": "aspnetcore.healthchecks.ui.client.9.0.0.nupkg.sha512" + }, + "AspNetCore.HealthChecks.UI.Core/9.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-TVriy4hgYnhfqz6NAzv8qe62Q8wf82iKUL6WV9selqeFZTq1ILi39Sic6sFQegRysvAVcnxKP/vY8z9Fk8x6XQ==", + "path": "aspnetcore.healthchecks.ui.core/9.0.0", + "hashPath": "aspnetcore.healthchecks.ui.core.9.0.0.nupkg.sha512" + }, "Autofac/4.6.2": { "type": "package", "serviceable": true, @@ -3546,33 +3605,33 @@ "path": "microsoft.aspnetcore.webutilities/2.2.0", "hashPath": "microsoft.aspnetcore.webutilities.2.2.0.nupkg.sha512" }, - "Microsoft.Azure.AppService.Middleware/1.5.5": { + "Microsoft.Azure.AppService.Middleware/1.5.7": { "type": "package", "serviceable": true, - "sha512": "sha512-RUM+aYNflH0ij8MGHo5oy7wC6lZ+qXBd8IC/uGHu5RzqZW06YAEEbMncEvWpan0chsRwYmysV7wF9oVUQBdj+A==", - "path": "microsoft.azure.appservice.middleware/1.5.5", - "hashPath": "microsoft.azure.appservice.middleware.1.5.5.nupkg.sha512" + "sha512": "sha512-95OOGpvM0niyXguEbpx2ujx47ayZ/m4oeX72K8GT6oCnvIKVqRDPCPGY3t0JfQM+vzv8K+a/WHgBgmihmi62Tg==", + "path": "microsoft.azure.appservice.middleware/1.5.7", + "hashPath": "microsoft.azure.appservice.middleware.1.5.7.nupkg.sha512" }, - "Microsoft.Azure.AppService.Middleware.Functions/1.5.5": { + "Microsoft.Azure.AppService.Middleware.Functions/1.5.7": { "type": "package", "serviceable": true, - "sha512": "sha512-GGBW1ysMcmd5jjF7fawDWXmjDUd1jc5YRW+C9xFhDLLBgGjdY2aoVvEMCQMdYvorewzFuWKPemsz23Z0Vgd92A==", - "path": "microsoft.azure.appservice.middleware.functions/1.5.5", - "hashPath": "microsoft.azure.appservice.middleware.functions.1.5.5.nupkg.sha512" + "sha512": "sha512-asoqht+DRZtREiS/BK5yhgOD93XpMEcRqR3pg/ODr36NJ1AbYKC6fsWl1Qt5DiW8MOras2ap83SUuRencUyjHw==", + "path": "microsoft.azure.appservice.middleware.functions/1.5.7", + "hashPath": "microsoft.azure.appservice.middleware.functions.1.5.7.nupkg.sha512" }, - "Microsoft.Azure.AppService.Middleware.Modules/1.5.5": { + "Microsoft.Azure.AppService.Middleware.Modules/1.5.7": { "type": "package", "serviceable": true, - "sha512": "sha512-FuO0tlr/Ld6Z5IBU6dodUSruITU7LTr4iG7YYEE40iWFxBvEsWx837mviDwxuLz/1byI8CWg7jUaPYjWpGkwHA==", - "path": "microsoft.azure.appservice.middleware.modules/1.5.5", - "hashPath": "microsoft.azure.appservice.middleware.modules.1.5.5.nupkg.sha512" + "sha512": "sha512-F7ZOdMhrlipdcWve1+DBqdh4cWC29d8RFzRu/ROpg6yiZ/XzfAazwr8MPWyzwVrQvKbyPPMnUuIFcafBh+2UVg==", + "path": "microsoft.azure.appservice.middleware.modules/1.5.7", + "hashPath": "microsoft.azure.appservice.middleware.modules.1.5.7.nupkg.sha512" }, - "Microsoft.Azure.AppService.Middleware.NetCore/1.5.5": { + "Microsoft.Azure.AppService.Middleware.NetCore/1.5.7": { "type": "package", "serviceable": true, - "sha512": "sha512-qKznCvVfwbag0CFa00refVL6e6JovkpA7ZUWgT/XCs7TLaSAFIX2jyjg57/RzltKReHrqgBkgq4LrL3zvTHnZQ==", - "path": "microsoft.azure.appservice.middleware.netcore/1.5.5", - "hashPath": "microsoft.azure.appservice.middleware.netcore.1.5.5.nupkg.sha512" + "sha512": "sha512-wJnm6MzwtkdCXsxxA73zM1tFt/LMMPi+M4TTrNTtu+Cej7JhF7zW1w6M7LCBxsMrPPx6MhzbEpL4CVvQzci6Zg==", + "path": "microsoft.azure.appservice.middleware.netcore/1.5.7", + "hashPath": "microsoft.azure.appservice.middleware.netcore.1.5.7.nupkg.sha512" }, "Microsoft.Azure.AppService.Proxy.Client/2.3.20240307.67": { "type": "package", @@ -3609,12 +3668,12 @@ "path": "microsoft.azure.functions.javaworker/2.19.2", "hashPath": "microsoft.azure.functions.javaworker.2.19.2.nupkg.sha512" }, - "Microsoft.Azure.Functions.NodeJsWorker/3.10.1": { + "Microsoft.Azure.Functions.NodeJsWorker/3.11.0": { "type": "package", "serviceable": true, - "sha512": "sha512-gASwzKUAd1ZiXIdzTN40xgTteFnFdU/XxUmppnIJSxjoKrLUJkmOcdmfInGe2Py6dknFhE1kwpBon+2oTVEBDg==", - "path": "microsoft.azure.functions.nodejsworker/3.10.1", - "hashPath": "microsoft.azure.functions.nodejsworker.3.10.1.nupkg.sha512" + "sha512": "sha512-eShZv3OLkLjNWoB+A2dRJ4SL3XG2YIBk/NkKpECI5gwBBZpfS4Tzqwbp/3ra9MQ/9qm33/SCjgn5taGEmNjxzg==", + "path": "microsoft.azure.functions.nodejsworker/3.11.0", + "hashPath": "microsoft.azure.functions.nodejsworker.3.11.0.nupkg.sha512" }, "Microsoft.Azure.Functions.Platform.Metrics.LinuxConsumption/1.0.5": { "type": "package", @@ -3675,7 +3734,7 @@ "Microsoft.Azure.WebJobs/3.0.41": { "type": "package", "serviceable": true, - "sha512": "sha512-EOigHt+kjrpbg53s8SYn4dlTpZG9IgWPNrdmcdSG8c7U8qKZvcF4BwZtF7ETy3KGir2NtIpJaIc7dUm2+k9/GA==", + "sha512": "sha512-nprqeSOAkhFrpIW1KVkXQb6BZCbnS8d1ytq0nUzIYnpkmbvmfkcQlJE9zp3Dbo26Q/0h0JdPSQ3BaVVBNPOUZg==", "path": "microsoft.azure.webjobs/3.0.41", "hashPath": "microsoft.azure.webjobs.3.0.41.nupkg.sha512" }, @@ -3731,7 +3790,7 @@ "Microsoft.Azure.WebJobs.Script.Abstractions/1.0.4-preview": { "type": "package", "serviceable": true, - "sha512": "sha512-6yQIbQWV+Js168FJFPu0aIdwaVl6IkaIqZOuq9RT/8QgNFjIiHYE6w/bJjTDWzf4FccVgbYAz9nXWXd5u45OEg==", + "sha512": "sha512-U/aVn/i0P9MdPsca9TrLmTlKuGOJ8okTdifrBeNviwd80Xbv/+dIM/GGReQXaVVtUM5/SlbUHRAhvA9w0yuEyA==", "path": "microsoft.azure.webjobs.script.abstractions/1.0.4-preview", "hashPath": "microsoft.azure.webjobs.script.abstractions.1.0.4-preview.nupkg.sha512" }, @@ -3896,6 +3955,20 @@ "path": "microsoft.extensions.diagnostics.abstractions/9.0.6", "hashPath": "microsoft.extensions.diagnostics.abstractions.9.0.6.nupkg.sha512" }, + "Microsoft.Extensions.Diagnostics.HealthChecks/8.0.11": { + "type": "package", + "serviceable": true, + "sha512": "sha512-zLgN22Zp9pk8RHlwssRTexw4+a6wqOnKWN+VejdPn5Yhjql4XiBhkFo35Nu8mmqHIk/UEmmCnMGLWq75aFfkOw==", + "path": "microsoft.extensions.diagnostics.healthchecks/8.0.11", + "hashPath": "microsoft.extensions.diagnostics.healthchecks.8.0.11.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/8.0.11": { + "type": "package", + "serviceable": true, + "sha512": "sha512-So3JUdRxozRjvQ3cxU6F3nI/i4emDnjane6yMYcJhvTTTu29ltlIdoXjkFGRceIWz8yKvuEpzXItZ0x5GvN2nQ==", + "path": "microsoft.extensions.diagnostics.healthchecks.abstractions/8.0.11", + "hashPath": "microsoft.extensions.diagnostics.healthchecks.abstractions.8.0.11.nupkg.sha512" + }, "Microsoft.Extensions.FileProviders.Abstractions/9.0.6": { "type": "package", "serviceable": true, @@ -5044,12 +5117,12 @@ "path": "yarp.reverseproxy/2.0.1", "hashPath": "yarp.reverseproxy.2.0.1.nupkg.sha512" }, - "Microsoft.Azure.WebJobs.Script/4.1043.100-pr.25404.3": { + "Microsoft.Azure.WebJobs.Script/4.1043.100-pr.25425.3": { "type": "project", "serviceable": false, "sha512": "" }, - "Microsoft.Azure.WebJobs.Script.Grpc/4.1043.100-pr.25404.3": { + "Microsoft.Azure.WebJobs.Script.Grpc/4.1043.100-pr.25425.3": { "type": "project", "serviceable": false, "sha512": "" @@ -5065,4 +5138,4 @@ "sha512": "" } } -} \ No newline at end of file +} diff --git a/test/WebJobs.Script.Tests/WebJobs.Script.Tests.csproj b/test/WebJobs.Script.Tests/WebJobs.Script.Tests.csproj index 995b4182fc..4de38a7b33 100644 --- a/test/WebJobs.Script.Tests/WebJobs.Script.Tests.csproj +++ b/test/WebJobs.Script.Tests/WebJobs.Script.Tests.csproj @@ -22,7 +22,7 @@ - +