diff --git a/release_notes.md b/release_notes.md index 3ff0177864..55316e8d7b 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,7 +1,8 @@ -### Release notes - - -- Memory allocation optimizations in `ScriptStartupTypeLocator.GetExtensionsStartupTypesAsync` (#11012) -- Fix invocation timeout when incoming request contains "x-ms-invocation-id" header (#10980) +### Release notes + + +- Memory allocation optimizations in `ScriptStartupTypeLocator.GetExtensionsStartupTypesAsync` (#11012) +- Fix invocation timeout when incoming request contains "x-ms-invocation-id" header (#10980) +- Warn if .azurefunctions folder does not exist (#10967) \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/DependencyInjection/DependencyValidator/DependencyValidator.cs b/src/WebJobs.Script.WebHost/DependencyInjection/DependencyValidator/DependencyValidator.cs index 4fe404433e..3cff3eab34 100644 --- a/src/WebJobs.Script.WebHost/DependencyInjection/DependencyValidator/DependencyValidator.cs +++ b/src/WebJobs.Script.WebHost/DependencyInjection/DependencyValidator/DependencyValidator.cs @@ -9,6 +9,7 @@ using Microsoft.Azure.WebJobs.Script.Diagnostics; using Microsoft.Azure.WebJobs.Script.Eventing; using Microsoft.Azure.WebJobs.Script.FileProvisioning; +using Microsoft.Azure.WebJobs.Script.Host; using Microsoft.Azure.WebJobs.Script.Scale; using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics; using Microsoft.Azure.WebJobs.Script.Workers; @@ -48,6 +49,7 @@ private static ExpectedDependencyBuilder CreateExpectedDependencies() .Expect() .Expect() .Expect() + .Optional() // Conditionally registered. .Optional() // Used by powershell. .Optional() // Missing when host is offline. .Optional() // Conditionally registered. diff --git a/src/WebJobs.Script/DependencyInjection/ScriptStartupTypeLocator.cs b/src/WebJobs.Script/DependencyInjection/ScriptStartupTypeLocator.cs index 61d774eb83..43c3758c18 100644 --- a/src/WebJobs.Script/DependencyInjection/ScriptStartupTypeLocator.cs +++ b/src/WebJobs.Script/DependencyInjection/ScriptStartupTypeLocator.cs @@ -102,7 +102,7 @@ public async Task> GetExtensionsStartupTypesAsync() } } - bool isDotnetIsolatedApp = IsDotnetIsolatedApp(functionMetadataCollection, SystemEnvironment.Instance); + bool isDotnetIsolatedApp = Utility.IsDotnetIsolatedApp(SystemEnvironment.Instance, functionMetadataCollection); bool isDotnetApp = isPrecompiledFunctionApp || isDotnetIsolatedApp; var isLogicApp = SystemEnvironment.Instance.IsLogicApp(); @@ -340,12 +340,6 @@ void CollectError(Type extensionType, Version minimumVersion, ExtensionStartupTy } } - private bool IsDotnetIsolatedApp(IEnumerable functions, IEnvironment environment) - { - string workerRuntime = Utility.GetWorkerRuntime(functions, environment); - return workerRuntime?.Equals(RpcWorkerConstants.DotNetIsolatedLanguageWorkerName, StringComparison.OrdinalIgnoreCase) ?? false; - } - private ExtensionRequirementsInfo GetExtensionRequirementsInfo() { ExtensionRequirementsInfo requirementsInfo = _extensionRequirementOptions.Value.Bundles != null || _extensionRequirementOptions.Value.Extensions != null diff --git a/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs b/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs index a887ecf88a..95afe4f588 100644 --- a/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs +++ b/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs @@ -184,14 +184,24 @@ internal static class LoggerExtension "Referenced bundle {bundleId} of version {bundleVersion} does not meet the required minimum version of {minimumVersion}. Update your extension bundle reference in host.json to reference {minimumVersion2} or later."); private static readonly Action _hostJsonZipDeploymentIssue = - LoggerMessage.Define(LogLevel.Error, - new EventId(338, nameof(HostJsonZipDeploymentIssue)), - "No functions were found. A valid host.json file wasn't found in the package root. However, one was located at: {hostJsonFilesPath}. This state indicates that your deployment package was created incorrectly. For deployment package requirements, see https://aka.ms/deployment-zip-push."); + LoggerMessage.Define(LogLevel.Error, + new EventId(338, nameof(HostJsonZipDeploymentIssue)), + "No functions were found. A valid host.json file wasn't found in the package root. However, one was located at: {hostJsonFilesPath}. This state indicates that your deployment package was created incorrectly. For deployment package requirements, see https://aka.ms/deployment-zip-push."); private static readonly Action _noHostJsonFile = - LoggerMessage.Define(LogLevel.Information, - new EventId(339, nameof(NoHostJsonFile)), - "No functions were found. This can occur before you deploy code to your function app or when the host.json file is missing from the most recent deployment. Make sure that your deployment package includes the host.json file in the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies."); + LoggerMessage.Define(LogLevel.Information, + new EventId(339, nameof(NoHostJsonFile)), + "No functions were found. This can occur before you deploy code to your function app or when the host.json file is missing from the most recent deployment. Make sure that your deployment package includes the host.json file in the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies."); + + private static readonly Action _missingAzureFunctionsFolder = + LoggerMessage.Define(LogLevel.Warning, + new EventId(340, nameof(MissingAzureFunctionsFolder)), + "Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app. Make sure that your deployment package includes the .azurefunctions folder at the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies. If this is not intended to be a .NET isolated app, please ensure that the {functionWorkerRuntime} app setting is configured correctly."); + + private static readonly Action _incorrectAzureFunctionsFolderPath = + LoggerMessage.Define(LogLevel.Warning, + new EventId(341, nameof(IncorrectAzureFunctionsFolderPath)), + "Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app. However, it is found to be located at: {path}. Make sure that your deployment package includes the .azurefunctions folder at the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies. If this is not intended to be a .NET isolated app, please ensure that the {functionWorkerRuntime} app setting is configured correctly."); private static readonly Action _publishingMetrics = LoggerMessage.Define(LogLevel.Debug, new EventId(338, nameof(PublishingMetrics)), "{metrics}"); @@ -387,5 +397,15 @@ public static void NoHostJsonFile(this ILogger logger) { _noHostJsonFile(logger, null); } + + public static void MissingAzureFunctionsFolder(this ILogger logger) + { + _missingAzureFunctionsFolder(logger, EnvironmentSettingNames.FunctionWorkerRuntime, null); + } + + public static void IncorrectAzureFunctionsFolderPath(this ILogger logger, string path) + { + _incorrectAzureFunctionsFolderPath(logger, path, EnvironmentSettingNames.FunctionWorkerRuntime, null); + } } } \ No newline at end of file diff --git a/src/WebJobs.Script/Host/FunctionAppValidationService.cs b/src/WebJobs.Script/Host/FunctionAppValidationService.cs new file mode 100644 index 0000000000..582da52dac --- /dev/null +++ b/src/WebJobs.Script/Host/FunctionAppValidationService.cs @@ -0,0 +1,79 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Diagnostics.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Script.Host +{ + /// + /// A background service responsible for validating function app payload. + /// + internal sealed class FunctionAppValidationService : BackgroundService + { + private readonly IEnvironment _environment; + private readonly ILogger _logger; + private readonly IOptions _scriptOptions; + + public FunctionAppValidationService( + ILogger logger, + IOptions scriptOptions, + IEnvironment environment) + { + _scriptOptions = scriptOptions ?? throw new ArgumentNullException(nameof(scriptOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + if (!_scriptOptions.Value.IsStandbyConfiguration) + { + // Adding a delay to ensure that this validation does not impact the cold start performance + Utility.ExecuteAfterColdStartDelay(_environment, Validate, cancellationToken); + } + + await Task.CompletedTask; + } + + private void Validate() + { + try + { + string azureFunctionsDirPath = Path.Combine(_scriptOptions.Value.RootScriptPath, ScriptConstants.AzureFunctionsSystemDirectoryName); + + if (_scriptOptions.Value.RootScriptPath is not null && + !_scriptOptions.Value.IsDefaultHostConfig && + Utility.IsDotnetIsolatedApp(environment: _environment) && + !Directory.Exists(azureFunctionsDirPath)) + { + // Search for the .azurefunctions directory within nested directories to verify scenarios where it isn't located at the root. This situation occurs when a function app has been improperly zipped. + IEnumerable azureFunctionsDirectories = Directory.GetDirectories(_scriptOptions.Value.RootScriptPath, ScriptConstants.AzureFunctionsSystemDirectoryName, SearchOption.AllDirectories) + .Where(dir => !dir.Equals(azureFunctionsDirPath, StringComparison.OrdinalIgnoreCase)); + + if (azureFunctionsDirectories.Any()) + { + string azureFunctionsDirectoriesPath = string.Join(", ", azureFunctionsDirectories).Replace(_scriptOptions.Value.RootScriptPath, string.Empty); + _logger.IncorrectAzureFunctionsFolderPath(azureFunctionsDirectoriesPath); + } + else + { + _logger.MissingAzureFunctionsFolder(); + } + } + } + catch (Exception ex) + { + _logger.LogTrace("Unable to validate deployed function app payload", ex); + } + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script/ScriptHostBuilderExtensions.cs b/src/WebJobs.Script/ScriptHostBuilderExtensions.cs index 278bf4a78f..eb9e58e5d1 100644 --- a/src/WebJobs.Script/ScriptHostBuilderExtensions.cs +++ b/src/WebJobs.Script/ScriptHostBuilderExtensions.cs @@ -31,6 +31,7 @@ using Microsoft.Azure.WebJobs.Script.Extensibility; using Microsoft.Azure.WebJobs.Script.ExtensionBundle; using Microsoft.Azure.WebJobs.Script.FileProvisioning; +using Microsoft.Azure.WebJobs.Script.Host; using Microsoft.Azure.WebJobs.Script.Http; using Microsoft.Azure.WebJobs.Script.ManagedDependencies; using Microsoft.Azure.WebJobs.Script.Scale; @@ -175,6 +176,11 @@ public static IHostBuilder AddScriptHostCore(this IHostBuilder builder, ScriptAp builder.ConfigureServices((context, services) => { + if (!SystemEnvironment.Instance.IsPlaceholderModeEnabled()) + { + services.AddHostedService(); + } + services.AddSingleton(); services.AddSingleton(s => { diff --git a/src/WebJobs.Script/Utility.cs b/src/WebJobs.Script/Utility.cs index ff1ce8cf10..12b79f65b9 100644 --- a/src/WebJobs.Script/Utility.cs +++ b/src/WebJobs.Script/Utility.cs @@ -671,6 +671,12 @@ internal static bool IsSingleLanguage(IEnumerable functions, s return ContainsFunctionWithWorkerRuntime(filteredFunctions, workerRuntime); } + internal static bool IsDotnetIsolatedApp(IEnvironment environment, IEnumerable functions = null) + { + string workerRuntime = GetWorkerRuntime(functions, environment); + return workerRuntime?.Equals(RpcWorkerConstants.DotNetIsolatedLanguageWorkerName, StringComparison.OrdinalIgnoreCase) ?? false; + } + internal static string GetWorkerRuntime(IEnumerable functions, IEnvironment environment = null) { if (environment != null) diff --git a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs index 29ba671cab..314d937c72 100644 --- a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs +++ b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs @@ -873,6 +873,46 @@ public async Task ResponseCompressionWorksAfterSpecialization(string acceptEncod Assert.Equal(expectedContentEncodingResponseHeaderValue, value?.First()); } + [Fact] + public async Task Specialization_DotnetIsolatedApp_MissingAzureFunctionsDir_Logs() + { + Guid guid = Guid.NewGuid(); + string path = "test-path" + guid.ToString(); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + string json = "{\r\n \"version\": \"2.0\",\r\n \"isDefaultHostConfig\": false\r\n}"; + File.WriteAllText(Path.Combine(path, "host.json"), json); + + var builder = InitializeDotNetIsolatedPlaceholderBuilder(path); + + using var testServer = new TestServer(builder); + + var standbyManager = testServer.Services.GetService(); + Assert.NotNull(standbyManager); + + _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteContainerReady, "1"); + _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "dotnet-isolated"); + SystemEnvironment.Instance.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "0"); + _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "0"); + + await standbyManager.SpecializeHostAsync(); + + // Assert: Verify that the host has specialized + var scriptHostManager = testServer.Services.GetService(); + Assert.NotNull(scriptHostManager); + Assert.Equal(ScriptHostState.Running, scriptHostManager.State); + + await TestHelpers.Await(() => + { + int completed = _loggerProvider.GetAllLogMessages().Count(p => p.FormattedMessage.Contains("Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app.")); + return completed > 0; + }); + } + [Fact] public async Task DotNetIsolated_PlaceholderHit_WithProxies() { diff --git a/test/WebJobs.Script.Tests/Description/FunctionAppValidationServiceTests.cs b/test/WebJobs.Script.Tests/Description/FunctionAppValidationServiceTests.cs new file mode 100644 index 0000000000..cd1848e234 --- /dev/null +++ b/test/WebJobs.Script.Tests/Description/FunctionAppValidationServiceTests.cs @@ -0,0 +1,151 @@ +// 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.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Description; +using Microsoft.Azure.WebJobs.Script.Host; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.WebJobs.Script.Tests; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests +{ + public class FunctionAppValidationServiceTests + { + private readonly ILogger _testLogger; + private readonly Mock> _scriptOptionsMock; + private readonly ScriptJobHostOptions _scriptJobHostOptions; + private readonly TestLoggerProvider _testLoggerProvider; + + public FunctionAppValidationServiceTests() + { + _scriptJobHostOptions = new ScriptJobHostOptions + { + RootScriptPath = "test-root-path", + IsDefaultHostConfig = false + }; + + _scriptOptionsMock = new Mock>(); + _scriptOptionsMock.Setup(o => o.Value).Returns(_scriptJobHostOptions); + + _testLoggerProvider = new TestLoggerProvider(); + var factory = new LoggerFactory(); + factory.AddProvider(_testLoggerProvider); + _testLogger = factory.CreateLogger(); + } + + [Fact] + public async Task StartAsync_NotDotnetIsolatedApp_DoesNotLogError() + { + _testLoggerProvider.ClearAllLogMessages(); + + var service = new FunctionAppValidationService( + _testLogger, + _scriptOptionsMock.Object, + new TestEnvironment()); + + // Act + await service.StartAsync(CancellationToken.None); + + //Assert + var traces = _testLoggerProvider.GetAllLogMessages(); + var traceMessage = traces.FirstOrDefault(val => val.EventId.Name.Equals("MissingAzureFunctionsFolder")); + + Assert.Null(traceMessage); + } + + [Fact] + public async Task StartAsync_PlaceholderMode_DoesNotLogError() + { + _testLoggerProvider.ClearAllLogMessages(); + + var environment = new TestEnvironment(); + environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "1"); + + var service = new FunctionAppValidationService( + _testLogger, + _scriptOptionsMock.Object, + environment); + + // Act + await service.StartAsync(CancellationToken.None); + + //Assert + var traces = _testLoggerProvider.GetAllLogMessages(); + var traceMessage = traces.FirstOrDefault(val => val.EventId.Name.Equals("MissingAzureFunctionsFolder")); + + Assert.Null(traceMessage); + } + + [Fact] + public async Task StartAsync_NewAppWithNoPayload_DoesNotLogError() + { + _testLoggerProvider.ClearAllLogMessages(); + + var environment = new TestEnvironment(); + environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "dotnet-isolated"); + + var scriptOptionsMock = new Mock>(); + + var scriptJobHostOptions = new ScriptJobHostOptions + { + RootScriptPath = "test-root-path", + IsDefaultHostConfig = true + }; + + scriptOptionsMock.Setup(o => o.Value).Returns(scriptJobHostOptions); + + var service = new FunctionAppValidationService( + _testLogger, + scriptOptionsMock.Object, + environment); + + // Act + await service.StartAsync(CancellationToken.None); + + //Assert + var traces = _testLoggerProvider.GetAllLogMessages(); + var traceMessage = traces.FirstOrDefault(val => val.EventId.Name.Equals("MissingAzureFunctionsFolder")); + + Assert.Null(traceMessage); + } + + [Fact] + public async Task StartAsync_MissingAzureFunctionsFolder_LogsWarning() + { + _testLoggerProvider.ClearAllLogMessages(); + + string path = "test-root-path"; + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + var functionMetadataList = ImmutableArray.Create(new FunctionMetadata()); + + var environment = new TestEnvironment(); + environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "dotnet-isolated"); + + var service = new FunctionAppValidationService( + _testLogger, + _scriptOptionsMock.Object, + environment); + + // Act + await service.StartAsync(CancellationToken.None); + + await TestHelpers.Await(() => + { + int completed = _testLoggerProvider.GetAllLogMessages().Count(p => p.FormattedMessage.Contains("Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app.")); + return completed > 0; + }); + } + } +} \ No newline at end of file