diff --git a/release_notes.md b/release_notes.md index ca06990915..a23619597a 100644 --- a/release_notes.md +++ b/release_notes.md @@ -5,4 +5,5 @@ --> - Update Python Worker Version to [4.40.2](https://github.com/Azure/azure-functions-python-worker/releases/tag/4.40.2) - Add JitTrace Files for v4.1044 +- Implementing a resolver that resolves worker configurations from specified probing paths (#11258) - Avoid emitting empty tag values for health check metrics (#11393) diff --git a/src/WebJobs.Script.WebHost/WebHostServiceCollectionExtensions.cs b/src/WebJobs.Script.WebHost/WebHostServiceCollectionExtensions.cs index 4ccee105d1..b77c6434ac 100644 --- a/src/WebJobs.Script.WebHost/WebHostServiceCollectionExtensions.cs +++ b/src/WebJobs.Script.WebHost/WebHostServiceCollectionExtensions.cs @@ -239,7 +239,18 @@ public static void AddWebJobsScriptHost(this IServiceCollection services, IConfi services.ConfigureOptionsWithChangeTokenSource>(); services.ConfigureOptionsWithChangeTokenSource>(); - services.AddSingleton(); + services.AddSingleton(p => + { + var workerConfigurationResolverOptions = p.GetService>(); + var workerProfileManager = p.GetService(); + var loggerFactory = p.GetService(); + var metricsLogger = p.GetService(); + + return workerConfigurationResolverOptions?.CurrentValue?.IsDynamicWorkerResolutionEnabled is true ? + new DynamicWorkerConfigurationResolver(loggerFactory, metricsLogger, FileUtility.Instance, workerProfileManager, SystemRuntimeInformation.Instance, workerConfigurationResolverOptions) : + new DefaultWorkerConfigurationResolver(loggerFactory, metricsLogger, FileUtility.Instance, workerProfileManager, SystemRuntimeInformation.Instance, workerConfigurationResolverOptions); + }); + services.TryAddSingleton(); services.TryAddSingleton(s => DefaultMiddlewarePipeline.Empty); diff --git a/src/WebJobs.Script/Config/FunctionsHostingConfigOptions.cs b/src/WebJobs.Script/Config/FunctionsHostingConfigOptions.cs index 56891f6344..ee733336bd 100644 --- a/src/WebJobs.Script/Config/FunctionsHostingConfigOptions.cs +++ b/src/WebJobs.Script/Config/FunctionsHostingConfigOptions.cs @@ -70,6 +70,29 @@ internal string WorkerIndexingDisabledApps } } + /// + /// Gets a string delimited by '|' that contains the names of the language workers available through probing paths outside of the Host. + /// + internal string WorkersAvailableForDynamicResolution + { + get + { + return GetFeature(RpcWorkerConstants.WorkersAvailableForDynamicResolution) ?? string.Empty; + } + } + + /// + /// Gets a string delimited by '|' that contains the versions of language workers to be ignored during probing outside of the Host. + /// Example value: "Worker1Name:Version1|Worker1Name:Version2|Worker2Name:Version1|Worker3Name:Version1". + /// + internal string IgnoredWorkerVersions + { + get + { + return GetFeature(RpcWorkerConstants.IgnoredWorkerVersions) ?? string.Empty; + } + } + /// /// Gets a value indicating whether Linux Log Backoff is disabled in the hosting config. /// diff --git a/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs b/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs index d28c331eb6..6ba90c7552 100644 --- a/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs +++ b/src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs @@ -216,6 +216,11 @@ internal static class LoggerExtension new EventId(343, nameof(DefaultWorkersDirectoryPath)), "Workers Directory set to: {workersDirPath}"); + private static readonly Action _workerProbingPaths = + LoggerMessage.Define(LogLevel.Debug, + new EventId(344, nameof(WorkerProbingPaths)), + "Worker probing paths set to: {workerProbingPaths}"); + public static void PublishingMetrics(this ILogger logger, string metrics) { _publishingMetrics(logger, metrics, null); @@ -423,6 +428,11 @@ public static void DefaultWorkersDirectoryPath(this ILogger logger, string worke _defaultWorkersDirectoryPath(logger, workersDirPath, null); } + public static void WorkerProbingPaths(this ILogger logger, string workerProbingPaths) + { + _workerProbingPaths(logger, workerProbingPaths, null); + } + public static void OutdatedExtensionBundle(this ILogger logger, string currentVersion) { _outdatedExtensionBundle(logger, currentVersion, null); diff --git a/src/WebJobs.Script/Diagnostics/MetricEventNames.cs b/src/WebJobs.Script/Diagnostics/MetricEventNames.cs index a0aa9748e5..7fd8d75e39 100644 --- a/src/WebJobs.Script/Diagnostics/MetricEventNames.cs +++ b/src/WebJobs.Script/Diagnostics/MetricEventNames.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. namespace Microsoft.Azure.WebJobs.Script.Diagnostics diff --git a/src/WebJobs.Script/Environment/EnvironmentExtensions.cs b/src/WebJobs.Script/Environment/EnvironmentExtensions.cs index 4f48232b45..5e434f2d76 100644 --- a/src/WebJobs.Script/Environment/EnvironmentExtensions.cs +++ b/src/WebJobs.Script/Environment/EnvironmentExtensions.cs @@ -278,6 +278,16 @@ public static bool IsWindowsElasticPremium(this IEnvironment environment) return string.Equals(value, ScriptConstants.ElasticPremiumSku, StringComparison.OrdinalIgnoreCase); } + /// + /// Gets a value indicating whether the application is running in a Windows App Service environment. + /// + /// The environment to verify. + /// if running in a Windows App Service app; otherwise, false. + public static bool IsHostedWindowsEnvironment(this IEnvironment environment) + { + return environment.IsWindowsAzureManagedHosting() || environment.IsWindowsConsumption() || environment.IsWindowsElasticPremium(); + } + public static bool IsDynamicSku(this IEnvironment environment) { return environment.IsConsumptionSku() || environment.IsWindowsElasticPremium(); @@ -687,6 +697,16 @@ public static bool IsInProc(this IEnvironment environment, string workerRuntime return string.IsNullOrEmpty(workerRuntime) || string.Equals(workerRuntime, RpcWorkerConstants.DotNetLanguageWorkerName, StringComparison.OrdinalIgnoreCase); } + /// + /// Returns the Antares platform release channel specified by the environment variable. + /// Value of this setting could be "LATEST", "STANDARD" or "EXTENDED". + /// If the environment variable is not set, the method returns the default value "LATEST". + /// + public static string GetPlatformReleaseChannel(this IEnvironment environment) + { + return environment.GetEnvironmentVariable(AntaresPlatformReleaseChannel) ?? ScriptConstants.LatestPlatformChannelNameUpper; + } + public static bool IsApplicationInsightsAgentEnabled(this IEnvironment environment) { // cache the value of the environment variable diff --git a/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs b/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs index cea779be92..04e8c76cd5 100644 --- a/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs +++ b/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. namespace Microsoft.Azure.WebJobs.Script @@ -35,6 +35,7 @@ public static class EnvironmentSettingNames public const string AppInsightsAgent = "APPLICATIONINSIGHTS_ENABLE_AGENT"; public const string FunctionsExtensionVersion = "FUNCTIONS_EXTENSION_VERSION"; public const string FunctionWorkerRuntime = "FUNCTIONS_WORKER_RUNTIME"; + public const string WorkerProbingPaths = "WORKER_PROBING_PATHS"; public const string ContainerName = "CONTAINER_NAME"; public const string WebsitePodName = "WEBSITE_POD_NAME"; public const string LegionServiceHost = "LEGION_SERVICE_HOST"; diff --git a/src/WebJobs.Script/JsonSerializerOptionsProvider.cs b/src/WebJobs.Script/JsonSerializerOptionsProvider.cs index 85035cde1a..6faf5ec53a 100644 --- a/src/WebJobs.Script/JsonSerializerOptionsProvider.cs +++ b/src/WebJobs.Script/JsonSerializerOptionsProvider.cs @@ -19,6 +19,12 @@ public static class JsonSerializerOptionsProvider /// public static readonly JsonSerializerOptions Options = CreateJsonOptions(); + /// + /// Shared Json serializer with the following setting: + /// - PropertyNameCaseInsensitive: true. + /// + public static readonly JsonSerializerOptions CaseInsensitiveJsonSerializerOptions = CreateCaseInsensitiveJsonOptions(); + private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions @@ -32,5 +38,10 @@ private static JsonSerializerOptions CreateJsonOptions() return options; } + + private static JsonSerializerOptions CreateCaseInsensitiveJsonOptions() + { + return new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + } } -} +} \ No newline at end of file diff --git a/src/WebJobs.Script/ScriptConstants.cs b/src/WebJobs.Script/ScriptConstants.cs index c050973d30..98a59a1c75 100644 --- a/src/WebJobs.Script/ScriptConstants.cs +++ b/src/WebJobs.Script/ScriptConstants.cs @@ -133,6 +133,7 @@ public static class ScriptConstants public const string FeatureFlagDisableWebHostLogForwarding = "DisableWebHostLogForwarding"; public const string FeatureFlagDisableMergedWebHostScriptHostConfiguration = "DisableMergedConfiguration"; public const string FeatureFlagEnableWorkerIndexing = "EnableWorkerIndexing"; + public const string FeatureFlagDisableDynamicWorkerResolution = "DisableDynamicWorkerResolution"; public const string FeatureFlagEnableDebugTracing = "EnableDebugTracing"; public const string FeatureFlagEnableProxies = "EnableProxies"; public const string FeatureFlagStrictHISModeEnabled = "StrictHISModeEnabled"; @@ -248,6 +249,7 @@ public static class ScriptConstants public static readonly long DefaultMaxRequestBodySize = 104857600; public static readonly ImmutableArray SystemLogCategoryPrefixes = ImmutableArray.Create("Microsoft.Azure.WebJobs.", "Function.", "Worker.", "Host."); + public static readonly ImmutableHashSet HostCapabilities = ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase); public static readonly string FunctionMetadataDirectTypeKey = "DirectType"; public static readonly string LiveLogsSessionAIKey = "#AzFuncLiveLogsSessionId"; diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/DefaultWorkerConfigurationResolver.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/DefaultWorkerConfigurationResolver.cs index 6882078bc7..0ce07e80cf 100644 --- a/src/WebJobs.Script/Workers/Rpc/Configuration/DefaultWorkerConfigurationResolver.cs +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/DefaultWorkerConfigurationResolver.cs @@ -1,50 +1,35 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Abstractions; +using Microsoft.Azure.WebJobs.Script.Diagnostics; using Microsoft.Azure.WebJobs.Script.Diagnostics.Extensions; +using Microsoft.Azure.WebJobs.Script.Workers.Profiles; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration { // This class resolves worker configurations by scanning the "workers" directory within the Host for worker config files. - internal sealed class DefaultWorkerConfigurationResolver : IWorkerConfigurationResolver + internal sealed class DefaultWorkerConfigurationResolver(ILoggerFactory loggerFactory, + IMetricsLogger metricsLogger, + IFileSystem fileSystem, + IWorkerProfileManager workerProfileManager, + ISystemRuntimeInformation systemRuntimeInformation, + IOptionsMonitor workerConfigurationResolverOptions) + : WorkerConfigurationResolverBase(loggerFactory, metricsLogger, fileSystem, workerProfileManager, systemRuntimeInformation, workerConfigurationResolverOptions) { - private readonly ILogger _logger; - private readonly IOptionsMonitor _workerConfigurationResolverOptions; - private readonly IFileSystem _fileSystem; - - public DefaultWorkerConfigurationResolver(ILoggerFactory loggerFactory, - IFileSystem fileSystem, - IOptionsMonitor workerConfigurationResolverOptions) + public override Dictionary GetWorkerConfigs() { - ArgumentNullException.ThrowIfNull(loggerFactory); - _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryWorkerConfig); - _workerConfigurationResolverOptions = workerConfigurationResolverOptions ?? throw new ArgumentNullException(nameof(workerConfigurationResolverOptions)); - _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - } - - public WorkerConfigurationInfo GetConfigurationInfo() - { - var workersRootDirPath = _workerConfigurationResolverOptions.CurrentValue.WorkersRootDirPath; - _logger.DefaultWorkersDirectoryPath(workersRootDirPath); - - var workerConfigPaths = new List(); + Logger.DefaultWorkersDirectoryPath(WorkerResolverOptions.WorkersRootDirPath); - foreach (var workerDir in _fileSystem.Directory.EnumerateDirectories(workersRootDirPath)) - { - string workerConfigPath = _fileSystem.Path.Combine(workerDir, RpcWorkerConstants.WorkerConfigFileName); + var workerRuntimeToConfigMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (_fileSystem.File.Exists(workerConfigPath)) - { - workerConfigPaths.Add(workerDir); - } - } + ResolveWorkerConfigsFromWithinHost(workerRuntimeToConfigMap); - return new WorkerConfigurationInfo(_workerConfigurationResolverOptions.CurrentValue.WorkersRootDirPath, workerConfigPaths); + return workerRuntimeToConfigMap; } } } diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/DynamicWorkerConfigurationResolver.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/DynamicWorkerConfigurationResolver.cs new file mode 100644 index 0000000000..464689396e --- /dev/null +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/DynamicWorkerConfigurationResolver.cs @@ -0,0 +1,249 @@ +// 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.IO.Abstractions; +using System.Text.Json; +using Microsoft.Azure.WebJobs.Script.Diagnostics; +using Microsoft.Azure.WebJobs.Script.Diagnostics.Extensions; +using Microsoft.Azure.WebJobs.Script.Workers.Profiles; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration +{ + /// + /// This class resolves worker configurations dynamically based on the current environment and configuration settings. + /// It searches for worker configs in specified probing paths and the fallback path, and returns a list of worker configurations. + /// + internal sealed class DynamicWorkerConfigurationResolver(ILoggerFactory loggerFactory, + IMetricsLogger metricsLogger, + IFileSystem fileSystem, + IWorkerProfileManager workerProfileManager, + ISystemRuntimeInformation systemRuntimeInformation, + IOptionsMonitor workerConfigurationResolverOptions) + : WorkerConfigurationResolverBase(loggerFactory, metricsLogger, fileSystem, workerProfileManager, systemRuntimeInformation, workerConfigurationResolverOptions) + { + /// + /// Retrieves a dictionary of worker configurations by searching the probing paths and fallback path. + /// The returned dictionary maps FUNCTIONS_WORKER_RUNTIME values to the corresponding RpcWorkerConfig - { FUNCTIONS_WORKER_RUNTIME : RpcWorkerConfig }. + /// + public override Dictionary GetWorkerConfigs() + { + var workerRuntime = WorkerResolverOptions.WorkerRuntime; + var workerProbingPaths = WorkerResolverOptions.ProbingPaths; + + // Search for worker configs in probing paths + var workerRuntimeConfigMap = ResolveWorkerConfigsFromProbingPaths(workerProbingPaths, workerRuntime); + + // Return if required worker config has been found + if (!WorkerResolverOptions.IsMultiLanguageWorkerEnvironment && !WorkerResolverOptions.IsPlaceholderModeEnabled && !string.IsNullOrWhiteSpace(workerRuntime) && workerRuntimeConfigMap.ContainsKey(workerRuntime)) + { + return workerRuntimeConfigMap; + } + + Logger.LogDebug("Searching for worker configs in the fallback directory: {fallbackPath}", WorkerResolverOptions.WorkersRootDirPath); + + ResolveWorkerConfigsFromWithinHost(workerRuntimeConfigMap); + + return workerRuntimeConfigMap; + } + + /// + /// Resolves worker configurations from the specified probing paths. + /// + private Dictionary ResolveWorkerConfigsFromProbingPaths(IReadOnlyList workerProbingPaths, string workerRuntime) + { + var workerRuntimeToConfigMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + Logger.WorkerProbingPaths(string.Join(", ", workerProbingPaths)); + + // Probing path directory structure is: "///worker.config.json" + foreach (var probingPath in workerProbingPaths) + { + if (!IsValidProbingPath(probingPath)) + { + continue; + } + + foreach (var workerRuntimePath in FileSystem.Directory.EnumerateDirectories(probingPath)) + { + var workerRuntimeDir = Path.GetFileName(workerRuntimePath); + + // If probing paths are malformed and have duplicate directories of the same language worker (eg. due to different casing) + if (workerRuntimeToConfigMap.ContainsKey(workerRuntimeDir)) + { + Logger.LogDebug("Skipping duplicate worker runtime directory '{workerRuntimeDir}' in probing path '{probingPath}'.", workerRuntimeDir, probingPath); + continue; + } + + // Skip worker directories that don't match the current runtime or are not enabled via hosting config. Do not load all workers after the specialization is done and if it is not a multi-language runtime environment + if (!WorkerResolverOptions.WorkersAvailableForResolution.Contains(workerRuntimeDir) || + WorkerConfigurationHelper.ShouldSkipWorkerDirectory(workerRuntime, workerRuntimeDir, WorkerResolverOptions.IsMultiLanguageWorkerEnvironment, WorkerResolverOptions.IsPlaceholderModeEnabled)) + { + continue; + } + + // Search for worker config inside version directories within the language worker directory + var resolvedWorkerConfig = ResolveWorkerConfigFromVersionsDirs(workerRuntimePath, workerRuntimeDir); + if (resolvedWorkerConfig is not null) + { + workerRuntimeToConfigMap[workerRuntimeDir] = resolvedWorkerConfig; + } + } + } + } + catch (Exception ex) + { + // Catching exceptions such as unauthorized access, IO exception, path too long, that can happen while searching for configs in probing paths. + // Logging the exception and continuing to search worker configs in the fallback path. + Logger.LogError(ex, "Failed to resolve worker configurations from probing paths."); + } + + return workerRuntimeToConfigMap; + } + + /// + /// Resolves worker configuration from version directories within a language worker directory. + /// + private RpcWorkerConfig ResolveWorkerConfigFromVersionsDirs(string languageWorkerPath, string languageWorkerFolder) + { + var versionPathMap = GetWorkerVersionsDescending(languageWorkerPath); + var standardOrExtendedChannel = IsStandardOrExtendedChannel(); + + var compatibleWorkerCount = 0; + (string resolvedWorkerVersionPath, JsonElement resolvedWorkerConfig, RpcWorkerDescription resolvedWorkerDescription) = (null, default, null); + + foreach (var versionPair in versionPathMap) + { + if (WorkerResolverOptions.IgnoredWorkerVersions.TryGetValue(languageWorkerFolder, out HashSet value) && value.Contains(versionPair.Key)) + { + Logger.LogDebug("Ignoring {languageWorkerFolder} version {version} as per configuration.", languageWorkerFolder, versionPair.Key); + continue; + } + + var languageWorkerVersionPath = versionPair.Value; + + (var workerDescription, var workerConfigJson) = WorkerConfigurationHelper.GetWorkerDescriptionAndConfig(languageWorkerVersionPath, ProfileManager, WorkerResolverOptions.WorkerDescriptionOverrides, Logger); + if (workerDescription is null || WorkerConfigurationHelper.IsWorkerDescriptionDisabled(workerDescription, Logger)) + { + continue; + } + + if (IsWorkerCompatibleWithHost(languageWorkerVersionPath, workerConfigJson)) + { + compatibleWorkerCount++; + (resolvedWorkerVersionPath, resolvedWorkerConfig, resolvedWorkerDescription) = (languageWorkerVersionPath, workerConfigJson, workerDescription); + + // If it is standard or extended channel, look for the next compatible worker and break. + if (!standardOrExtendedChannel || compatibleWorkerCount > 1) + { + break; + } + } + } + + if (resolvedWorkerVersionPath is null) + { + return null; + } + + return WorkerConfigurationHelper.BuildWorkerConfig(WorkerResolverOptions, resolvedWorkerVersionPath, resolvedWorkerConfig, resolvedWorkerDescription, MetricsLogger, Logger, SystemRuntimeInformation); + } + + /// + /// Returns a sorted list of worker version directories in descending order. + /// + private SortedList GetWorkerVersionsDescending(string languageWorkerPath) + { + var workerVersionPaths = FileSystem.Directory.EnumerateDirectories(languageWorkerPath); + + // Map of: (parsed worker version, worker path). Example: [ (2.0.0, "/java/2.0.0"), (1.0.0, "/java/1.0.0") ] + var versionPathMap = new SortedList(DescendingVersionComparer.Instance); + + foreach (var workerVersionPath in workerVersionPaths) + { + var versionDir = Path.GetFileName(workerVersionPath); + + if (Version.TryParse(versionDir, out Version version)) + { + versionPathMap[version] = workerVersionPath; + } + else + { + Logger.LogDebug("Failed to parse worker version '{versionDir}' as a valid version.", versionDir); + } + } + + return versionPathMap; + } + + /// + /// Determines if the worker is compatible with Host by checking if Host satisfies worker requirements. + /// + private bool IsWorkerCompatibleWithHost(string workerDirPath, JsonElement workerConfigJson) + { + if (workerConfigJson.TryGetProperty(RpcWorkerConstants.HostRequirementsSectionName, out JsonElement hostRequirementsSection)) + { + Logger.LogDebug("Worker configuration at '{workerDirPath}' specifies host requirements {requirements}.", workerDirPath, hostRequirementsSection); + + var hostRequirements = hostRequirementsSection.Deserialize>(JsonSerializerOptionsProvider.CaseInsensitiveJsonSerializerOptions); + + if (hostRequirements is not null && !hostRequirements.IsSubsetOf(ScriptConstants.HostCapabilities)) + { + return false; + } + } + + return true; + } + + /// + /// Checks if the provided probing path is valid by ensuring it is not null and the directory exists in the file system. + /// + private bool IsValidProbingPath(string probingPath) + { + if (string.IsNullOrWhiteSpace(probingPath)) + { + return false; + } + + if (!FileSystem.Directory.Exists(probingPath)) + { + Logger.LogDebug("Worker probing path directory does not exist: {probingPath}.", probingPath); + return false; + } + + return true; + } + + /// + /// Determines if the current release channel is either the standard or extended platform channel. + /// + private bool IsStandardOrExtendedChannel() + { + var releaseChannel = WorkerResolverOptions.ReleaseChannel; + + return !string.IsNullOrWhiteSpace(releaseChannel) && + (releaseChannel.Equals(ScriptConstants.StandardPlatformChannelNameUpper, StringComparison.OrdinalIgnoreCase) || + releaseChannel.Equals(ScriptConstants.ExtendedPlatformChannelNameUpper, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Comparer for sorting Version objects in descending order. + /// + private class DescendingVersionComparer : IComparer + { + public static readonly DescendingVersionComparer Instance = new(); + + public int Compare(Version version1, Version version2) + { + return version2.CompareTo(version1); // Inverted comparison for descending order + } + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/LanguageWorkerOptionsSetup.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/LanguageWorkerOptionsSetup.cs index 91ca43b096..492ccc0d4e 100644 --- a/src/WebJobs.Script/Workers/Rpc/Configuration/LanguageWorkerOptionsSetup.cs +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/LanguageWorkerOptionsSetup.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using Microsoft.Azure.WebJobs.Script.Diagnostics; using Microsoft.Azure.WebJobs.Script.Workers.Profiles; +using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -22,6 +23,7 @@ internal class LanguageWorkerOptionsSetup : IConfigureOptions _workerConfigurationResolverOptions; public LanguageWorkerOptionsSetup(IConfiguration configuration, ILoggerFactory loggerFactory, @@ -29,7 +31,8 @@ public LanguageWorkerOptionsSetup(IConfiguration configuration, IMetricsLogger metricsLogger, IWorkerProfileManager workerProfileManager, IScriptHostManager scriptHostManager, - IWorkerConfigurationResolver workerConfigurationResolver) + IWorkerConfigurationResolver workerConfigurationResolver, + IOptionsMonitor workerConfigResolverOptions) { if (loggerFactory is null) { @@ -42,6 +45,7 @@ public LanguageWorkerOptionsSetup(IConfiguration configuration, _metricsLogger = metricsLogger ?? throw new ArgumentNullException(nameof(metricsLogger)); _workerProfileManager = workerProfileManager ?? throw new ArgumentNullException(nameof(workerProfileManager)); _workerConfigurationResolver = workerConfigurationResolver ?? throw new ArgumentNullException(nameof(workerConfigurationResolver)); + _workerConfigurationResolverOptions = workerConfigResolverOptions ?? throw new ArgumentNullException(nameof(workerConfigResolverOptions)); _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryWorkerConfig); } @@ -75,7 +79,7 @@ public void Configure(LanguageWorkerOptions options) } } - var configFactory = new RpcWorkerConfigFactory(configuration, _logger, SystemRuntimeInformation.Instance, _environment, _metricsLogger, _workerProfileManager, _workerConfigurationResolver); + var configFactory = new RpcWorkerConfigFactory(_logger, SystemRuntimeInformation.Instance, _metricsLogger, _workerProfileManager, _workerConfigurationResolver, _workerConfigurationResolverOptions); options.WorkerConfigs = configFactory.GetConfigs(); } } diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/RpcWorkerConfigFactory.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/RpcWorkerConfigFactory.cs index 7392b0bca1..0d203d6004 100644 --- a/src/WebJobs.Script/Workers/Rpc/Configuration/RpcWorkerConfigFactory.cs +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/RpcWorkerConfigFactory.cs @@ -2,55 +2,42 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; -using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Script.Diagnostics; using Microsoft.Azure.WebJobs.Script.Workers.Profiles; using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc { // Gets fully configured WorkerConfigs from IWorkerProviders internal class RpcWorkerConfigFactory { - private readonly IConfiguration _config; private readonly ILogger _logger; private readonly ISystemRuntimeInformation _systemRuntimeInformation; private readonly IWorkerProfileManager _profileManager; private readonly IMetricsLogger _metricsLogger; - private readonly string _workerRuntime; - private readonly IEnvironment _environment; private readonly IWorkerConfigurationResolver _workerConfigurationResolver; - private readonly JsonSerializerOptions _jsonSerializerOptions = new() - { - PropertyNameCaseInsensitive = true - }; - + private readonly IOptionsMonitor _resolverOptions; private Dictionary _workerDescriptionDictionary = new Dictionary(); - public RpcWorkerConfigFactory(IConfiguration config, - ILogger logger, + public RpcWorkerConfigFactory(ILogger logger, ISystemRuntimeInformation systemRuntimeInfo, - IEnvironment environment, IMetricsLogger metricsLogger, IWorkerProfileManager workerProfileManager, - IWorkerConfigurationResolver workerConfigurationResolver) + IWorkerConfigurationResolver workerConfigurationResolver, + IOptionsMonitor workerConfigurationResolverOptions) { - _config = config ?? throw new ArgumentNullException(nameof(config)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _systemRuntimeInformation = systemRuntimeInfo ?? throw new ArgumentNullException(nameof(systemRuntimeInfo)); - _environment = environment ?? throw new ArgumentNullException(nameof(environment)); _metricsLogger = metricsLogger ?? throw new ArgumentNullException(nameof(metricsLogger)); _profileManager = workerProfileManager ?? throw new ArgumentNullException(nameof(workerProfileManager)); - _workerRuntime = _environment.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime); _workerConfigurationResolver = workerConfigurationResolver ?? throw new ArgumentNullException(nameof(workerConfigurationResolver)); + _resolverOptions = workerConfigurationResolverOptions ?? throw new ArgumentNullException(nameof(workerConfigurationResolverOptions)); + ArgumentNullException.ThrowIfNull(_resolverOptions.CurrentValue); } public IList GetConfigs() @@ -64,316 +51,20 @@ public IList GetConfigs() internal void BuildWorkerProviderDictionary() { - var workerConfigurationInfo = _workerConfigurationResolver.GetConfigurationInfo(); - - AddProviders(workerConfigurationInfo); - AddProvidersFromAppSettings(workerConfigurationInfo); - } - - internal void AddProviders(WorkerConfigurationInfo workerConfigurationInfo) - { - var workerConfigs = workerConfigurationInfo.WorkerConfigPaths; - - foreach (var workerConfig in workerConfigs) - { - AddProvider(workerConfig, workerConfigurationInfo.WorkersRootDirPath); - } - } - - internal void AddProvidersFromAppSettings(WorkerConfigurationInfo workerConfigurationInfo) - { - var languagesSection = _config.GetSection($"{RpcWorkerConstants.LanguageWorkersSectionName}"); - foreach (var languageSection in languagesSection.GetChildren()) - { - var workerDirectorySection = languageSection.GetSection(WorkerConstants.WorkerDirectorySectionName); - if (workerDirectorySection.Value != null) - { - _workerDescriptionDictionary.Remove(languageSection.Key); - AddProvider(workerDirectorySection.Value, workerConfigurationInfo.WorkersRootDirPath); - } - } - } - - internal void AddProvider(string workerDir, string workersRootDirPath) - { - using (_metricsLogger.LatencyEvent(string.Format(MetricEventNames.AddProvider, workerDir))) - { - try - { - // After specialization, load worker config only for the specified runtime unless it's a multi-language app. - if (!string.IsNullOrWhiteSpace(_workerRuntime) && !_environment.IsPlaceholderModeEnabled() && !_environment.IsMultiLanguageRuntimeEnvironment()) - { - string workerRuntime = Path.GetFileName(workerDir); - // Only skip worker directories that don't match the current runtime. - // Do not skip non-worker directories like the function app payload directory - if (!workerRuntime.Equals(_workerRuntime, StringComparison.OrdinalIgnoreCase) && workerDir.StartsWith(workersRootDirPath)) - { - return; - } - } - - string workerConfigPath = Path.Combine(workerDir, RpcWorkerConstants.WorkerConfigFileName); - - if (!File.Exists(workerConfigPath)) - { - _logger.LogDebug("Did not find worker config file at: {workerConfigPath}", workerConfigPath); - return; - } - - _logger.LogDebug("Found worker config: {workerConfigPath}", workerConfigPath); - - var workerConfig = GetWorkerConfigJsonElement(workerConfigPath); - var workerDescriptionElement = workerConfig.GetProperty(WorkerConstants.WorkerDescription); - var workerDescription = workerDescriptionElement.Deserialize(_jsonSerializerOptions); - workerDescription.WorkerDirectory = workerDir; - - // Read the profiles from worker description and load the profile for which the conditions match - if (workerConfig.TryGetProperty(WorkerConstants.WorkerDescriptionProfiles, out var profiles)) - { - List workerDescriptionProfiles = ReadWorkerDescriptionProfiles(profiles); - if (workerDescriptionProfiles.Count > 0) - { - _profileManager.SetWorkerDescriptionProfiles(workerDescriptionProfiles, workerDescription.Language); - _profileManager.LoadWorkerDescriptionFromProfiles(workerDescription, out workerDescription); - } - } - - // Check if any app settings are provided for that language - var languageSection = _config.GetSection($"{RpcWorkerConstants.LanguageWorkersSectionName}:{workerDescription.Language}"); - workerDescription.Arguments ??= new List(); - GetWorkerDescriptionFromAppSettings(workerDescription, languageSection); - AddArgumentsFromAppSettings(workerDescription, languageSection); - - // Validate workerDescription - workerDescription.ApplyDefaultsAndValidate(Directory.GetCurrentDirectory(), _logger); - - if (workerDescription.IsDisabled == true) - { - _logger.LogInformation("Skipping WorkerConfig for stack: {language} since it is disabled.", workerDescription.Language); - return; - } - - if (ShouldAddWorkerConfig(workerDescription.Language)) - { - workerDescription.FormatWorkerPathIfNeeded(_systemRuntimeInformation, _environment, _logger); - workerDescription.FormatWorkingDirectoryIfNeeded(); - workerDescription.FormatArgumentsIfNeeded(_logger); - workerDescription.ThrowIfFileNotExists(workerDescription.DefaultWorkerPath, nameof(workerDescription.DefaultWorkerPath)); - workerDescription.ExpandEnvironmentVariables(); - - WorkerProcessCountOptions workerProcessCount = GetWorkerProcessCount(workerConfig); - - var arguments = new WorkerProcessArguments() - { - ExecutablePath = workerDescription.DefaultExecutablePath, - WorkerPath = workerDescription.DefaultWorkerPath - }; - - arguments.ExecutableArguments.AddRange(workerDescription.Arguments); - - var rpcWorkerConfig = new RpcWorkerConfig() - { - Description = workerDescription, - Arguments = arguments, - CountOptions = workerProcessCount, - }; - - _workerDescriptionDictionary[workerDescription.Language] = rpcWorkerConfig; - ReadLanguageWorkerFile(arguments.WorkerPath); - - _logger.LogDebug("Added WorkerConfig for language: {language}", workerDescription.Language); - } - } - catch (Exception ex) when (!ex.IsFatal()) - { - _logger.LogError(ex, "Failed to initialize worker provider for: {workerDir}", workerDir); - } - } - } - - private static JsonElement GetWorkerConfigJsonElement(string workerConfigPath) - { - ReadOnlySpan jsonSpan = File.ReadAllBytes(workerConfigPath); - - if (jsonSpan.StartsWith([0xEF, 0xBB, 0xBF])) - { - jsonSpan = jsonSpan[3..]; // Skip UTF-8 Byte Order Mark (BOM) if present at the beginning of the file. - } - - var reader = new Utf8JsonReader(jsonSpan, isFinalBlock: true, state: default); - using var doc = JsonDocument.ParseValue(ref reader); - - return doc.RootElement.Clone(); + _workerDescriptionDictionary = _workerConfigurationResolver.GetWorkerConfigs(); + AddProvidersFromAppSettings(); } - private List ReadWorkerDescriptionProfiles(JsonElement profilesElement) + internal void AddProvidersFromAppSettings() { - var profiles = profilesElement.Deserialize>(_jsonSerializerOptions); - - if (profiles == null || profiles.Count <= 0) - { - return new List(0); - } - - var descriptionProfiles = new List(profiles.Count); - - try + foreach (var (language, workerDescriptionOverride) in _resolverOptions.CurrentValue.WorkerDescriptionOverrides) { - foreach (var profile in profiles) + if (!string.IsNullOrEmpty(workerDescriptionOverride?.WorkerDirectory)) { - var profileConditions = new List(profile.Conditions.Count); - - foreach (var descriptor in profile.Conditions) - { - if (!_profileManager.TryCreateWorkerProfileCondition(descriptor, out IWorkerProfileCondition condition)) - { - // Failed to resolve condition. This profile will be disabled using a mock false condition - _logger.LogInformation("Profile {name} is disabled. Cannot resolve the profile condition {condition}", profile.ProfileName, descriptor.Type); - condition = new FalseCondition(); - } - - profileConditions.Add(condition); - } - - descriptionProfiles.Add(new(profile.ProfileName, profileConditions, profile.Description)); + _workerDescriptionDictionary.Remove(language); + WorkerConfigurationHelper.AddProvider(_resolverOptions.CurrentValue, workerDescriptionOverride.WorkerDirectory, _metricsLogger, _profileManager, _logger, _systemRuntimeInformation, _workerDescriptionDictionary); } } - catch (Exception) - { - throw new FormatException("Failed to parse profiles in worker config."); - } - - return descriptionProfiles; - } - - internal WorkerProcessCountOptions GetWorkerProcessCount(JsonElement workerConfig) - { - WorkerProcessCountOptions workerProcessCount = null; - - if (workerConfig.TryGetProperty(WorkerConstants.ProcessCount, out var processCountElement)) - { - workerProcessCount = processCountElement.Deserialize(_jsonSerializerOptions); - } - - workerProcessCount ??= new WorkerProcessCountOptions(); - - if (workerProcessCount.SetProcessCountToNumberOfCpuCores) - { - workerProcessCount.ProcessCount = _environment.GetEffectiveCoresCount(); - // set Max worker process count to Number of effective cores if MaxProcessCount is less than MinProcessCount - workerProcessCount.MaxProcessCount = workerProcessCount.ProcessCount > workerProcessCount.MaxProcessCount ? workerProcessCount.ProcessCount : workerProcessCount.MaxProcessCount; - } - - // Env variable takes precedence over worker.config - string processCountEnvSetting = _environment.GetEnvironmentVariable(RpcWorkerConstants.FunctionsWorkerProcessCountSettingName); - if (!string.IsNullOrEmpty(processCountEnvSetting)) - { - workerProcessCount.ProcessCount = int.Parse(processCountEnvSetting) > 1 ? int.Parse(processCountEnvSetting) : 1; - } - - // Validate - if (workerProcessCount.ProcessCount <= 0) - { - throw new ArgumentOutOfRangeException(nameof(workerProcessCount.ProcessCount), "ProcessCount must be greater than 0."); - } - if (workerProcessCount.ProcessCount > workerProcessCount.MaxProcessCount) - { - throw new ArgumentException($"{nameof(workerProcessCount.ProcessCount)} must not be greater than {nameof(workerProcessCount.MaxProcessCount)}"); - } - if (workerProcessCount.ProcessStartupInterval.Ticks < 0) - { - throw new ArgumentOutOfRangeException($"{nameof(workerProcessCount.ProcessStartupInterval)}", "The TimeSpan must not be negative."); - } - - return workerProcessCount; - } - - private static void GetWorkerDescriptionFromAppSettings(RpcWorkerDescription workerDescription, IConfigurationSection languageSection) - { - var defaultExecutablePathSetting = languageSection.GetSection($"{WorkerConstants.WorkerDescriptionDefaultExecutablePath}"); - workerDescription.DefaultExecutablePath = defaultExecutablePathSetting.Value != null ? defaultExecutablePathSetting.Value : workerDescription.DefaultExecutablePath; - - var defaultRuntimeVersionAppSetting = languageSection.GetSection($"{WorkerConstants.WorkerDescriptionDefaultRuntimeVersion}"); - workerDescription.DefaultRuntimeVersion = defaultRuntimeVersionAppSetting.Value != null ? defaultRuntimeVersionAppSetting.Value : workerDescription.DefaultRuntimeVersion; - } - - internal static void AddArgumentsFromAppSettings(RpcWorkerDescription workerDescription, IConfigurationSection languageSection) - { - var argumentsSection = languageSection.GetSection($"{WorkerConstants.WorkerDescriptionArguments}"); - if (argumentsSection.Value != null) - { - ((List)workerDescription.Arguments).AddRange(Regex.Split(argumentsSection.Value, @"\s+")); - } - } - - internal bool ShouldAddWorkerConfig(string workerDescriptionLanguage) - { - if (_environment.IsPlaceholderModeEnabled()) - { - return true; - } - - if (_environment.IsMultiLanguageRuntimeEnvironment()) - { - _logger.LogInformation("Found multi-language runtime environment. Starting WorkerConfig for language: {workerDescriptionLanguage}", workerDescriptionLanguage); - return true; - } - - if (!string.IsNullOrEmpty(_workerRuntime)) - { - _logger.LogDebug("EnvironmentVariable {functionWorkerRuntimeSettingName}: {workerRuntime}", EnvironmentSettingNames.FunctionWorkerRuntime, _workerRuntime); - if (_workerRuntime.Equals(workerDescriptionLanguage, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - // After specialization only create worker provider for the language set by FUNCTIONS_WORKER_RUNTIME env variable - _logger.LogInformation("{FUNCTIONS_WORKER_RUNTIME} set to {workerRuntime}. Skipping WorkerConfig for language: {workerDescriptionLanguage}", EnvironmentSettingNames.FunctionWorkerRuntime, _workerRuntime, workerDescriptionLanguage); - return false; - } - - return true; - } - - private void ReadLanguageWorkerFile(string workerPath) - { - if (!_environment.IsPlaceholderModeEnabled() - || string.IsNullOrWhiteSpace(_workerRuntime) - || !File.Exists(workerPath)) - { - return; - } - - // Reads the file to warm up the operating system's file cache. Can run in the background. - _ = Task.Run(() => - { - const int bufferSize = 4096; - var buffer = ArrayPool.Shared.Rent(bufferSize); - - try - { - using var fs = new FileStream( - workerPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize, - FileOptions.SequentialScan); - - while (fs.Read(buffer, 0, bufferSize) > 0) - { - // Do nothing. The goal is to read the file into the OS cache. - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error warming up worker file: {filePath}", workerPath); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - }); } } } diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationHelper.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationHelper.cs new file mode 100644 index 0000000000..dc4814b97f --- /dev/null +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationHelper.cs @@ -0,0 +1,403 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Diagnostics; +using Microsoft.Azure.WebJobs.Script.Workers.Profiles; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration +{ + internal static class WorkerConfigurationHelper + { + internal static void AddProvider(WorkerConfigurationResolverOptions resolverOptions, + string workerDirPath, + IMetricsLogger metricsLogger, + IWorkerProfileManager profileManager, + ILogger logger, + ISystemRuntimeInformation systemRuntimeInformation, + Dictionary workerRuntimeToConfigMap) + { + using (metricsLogger.LatencyEvent(string.Format(MetricEventNames.AddProvider, workerDirPath))) + { + string workerName = Path.GetFileName(workerDirPath); + + if (workerRuntimeToConfigMap.ContainsKey(workerName)) + { + return; + } + + // After specialization, load worker config only for the specified runtime unless it's a multi-language app. + // Only skip worker directories that don't match the current runtime. + // Do not skip non-worker directories like the function app payload directory + if (ShouldSkipWorkerDirectory(resolverOptions.WorkerRuntime, Path.GetFileName(workerDirPath), resolverOptions.IsMultiLanguageWorkerEnvironment, resolverOptions.IsPlaceholderModeEnabled) + && workerDirPath.StartsWith(resolverOptions.WorkersRootDirPath)) + { + return; + } + + (var workerDescription, var workerConfigJson) = GetWorkerDescriptionAndConfig(workerDirPath, profileManager, resolverOptions.WorkerDescriptionOverrides, logger); + if (workerDescription is null || IsWorkerDescriptionDisabled(workerDescription, logger)) + { + return; + } + + var workerConfig = BuildWorkerConfig(resolverOptions, workerDirPath, workerConfigJson, workerDescription, metricsLogger, logger, systemRuntimeInformation); + if (workerConfig is not null) + { + workerRuntimeToConfigMap[workerName] = workerConfig; + } + } + } + + internal static RpcWorkerConfig BuildWorkerConfig(WorkerConfigurationResolverOptions resolverOptions, + string workerDir, + JsonElement workerConfig, + RpcWorkerDescription workerDescription, + IMetricsLogger metricsLogger, + ILogger logger, + ISystemRuntimeInformation systemRuntimeInformation) + { + try + { + var workerRuntime = resolverOptions.WorkerRuntime; + + if (ShouldAddWorkerConfig(workerDescription.Language, resolverOptions.IsPlaceholderModeEnabled, resolverOptions.IsMultiLanguageWorkerEnvironment, logger, workerRuntime)) + { + workerDescription.FormatWorkerPathIfNeeded(systemRuntimeInformation, workerRuntime, resolverOptions.FunctionsWorkerRuntimeVersion, logger); + workerDescription.FormatWorkingDirectoryIfNeeded(); + workerDescription.FormatArgumentsIfNeeded(logger); + workerDescription.ThrowIfFileNotExists(workerDescription.DefaultWorkerPath, nameof(workerDescription.DefaultWorkerPath)); + workerDescription.ExpandEnvironmentVariables(); + + WorkerProcessCountOptions workerProcessCount = GetWorkerProcessCount(workerConfig, resolverOptions.WorkerProcessCount, resolverOptions.EffectiveCoresCount); + + var arguments = new WorkerProcessArguments() + { + ExecutablePath = workerDescription.DefaultExecutablePath, + WorkerPath = workerDescription.DefaultWorkerPath + }; + + arguments.ExecutableArguments.AddRange(workerDescription.Arguments); + + var rpcWorkerConfig = new RpcWorkerConfig() + { + Description = workerDescription, + Arguments = arguments, + CountOptions = workerProcessCount, + }; + + ReadLanguageWorkerFile(arguments.WorkerPath, resolverOptions.IsPlaceholderModeEnabled, logger, workerRuntime); + + logger.LogDebug("Added WorkerConfig for language: {language} with worker path: {path}", workerDescription.Language, workerDescription.DefaultWorkerPath); + + return rpcWorkerConfig; + } + } + catch (Exception ex) when (!ex.IsFatal()) + { + logger.LogError(ex, "Failed to initialize worker provider for: {workerDir}", workerDir); + } + + return null; + } + + private static JsonElement GetWorkerConfigJsonElement(string workerConfigPath, ILogger logger) + { + ReadOnlySpan jsonSpan = File.ReadAllBytes(workerConfigPath); + + if (jsonSpan.StartsWith([0xEF, 0xBB, 0xBF])) + { + jsonSpan = jsonSpan[3..]; // Skip UTF-8 Byte Order Mark (BOM) if present at the beginning of the file. + } + + if (jsonSpan.IsEmpty) + { + logger.LogDebug("Worker config at '{workerConfigPath}' is empty.", workerConfigPath); + return default; // Return default JsonElement if the file is empty. + } + + var reader = new Utf8JsonReader(jsonSpan, isFinalBlock: true, state: default); + using var doc = JsonDocument.ParseValue(ref reader); + + return doc.RootElement.Clone(); + } + + private static List ReadWorkerDescriptionProfiles(JsonElement profilesElement, + JsonSerializerOptions jsonSerializerOptions, + IWorkerProfileManager profileManager, + ILogger logger) + { + var profiles = profilesElement.Deserialize>(jsonSerializerOptions); + + if (profiles == null || profiles.Count <= 0) + { + return new List(0); + } + + var descriptionProfiles = new List(profiles.Count); + + try + { + foreach (var profile in profiles) + { + var profileConditions = new List(profile.Conditions.Count); + + foreach (var descriptor in profile.Conditions) + { + if (!profileManager.TryCreateWorkerProfileCondition(descriptor, out IWorkerProfileCondition condition)) + { + // Failed to resolve condition. This profile will be disabled using a mock false condition + logger.LogInformation("Profile {name} is disabled. Cannot resolve the profile condition {condition}", profile.ProfileName, descriptor.Type); + condition = new FalseCondition(); + } + + profileConditions.Add(condition); + } + + descriptionProfiles.Add(new(profile.ProfileName, profileConditions, profile.Description)); + } + } + catch (Exception) + { + throw new FormatException("Failed to parse profiles in worker config."); + } + + return descriptionProfiles; + } + + internal static WorkerProcessCountOptions GetWorkerProcessCount(JsonElement workerConfig, string functionsWorkerProcessCount, int coresCount) + { + WorkerProcessCountOptions workerProcessCount = null; + var jsonSerializerOptions = JsonSerializerOptionsProvider.CaseInsensitiveJsonSerializerOptions; + + if (workerConfig.TryGetProperty(WorkerConstants.ProcessCount, out var processCountElement)) + { + workerProcessCount = processCountElement.Deserialize(jsonSerializerOptions); + } + + workerProcessCount ??= new WorkerProcessCountOptions(); + + if (workerProcessCount.SetProcessCountToNumberOfCpuCores) + { + workerProcessCount.ProcessCount = coresCount; + // set Max worker process count to Number of effective cores if MaxProcessCount is less than MinProcessCount + workerProcessCount.MaxProcessCount = workerProcessCount.ProcessCount > workerProcessCount.MaxProcessCount ? workerProcessCount.ProcessCount : workerProcessCount.MaxProcessCount; + } + + // Env variable takes precedence over worker.config + string processCountEnvSetting = functionsWorkerProcessCount; + if (!string.IsNullOrEmpty(processCountEnvSetting)) + { + workerProcessCount.ProcessCount = int.Parse(processCountEnvSetting) > 1 ? int.Parse(processCountEnvSetting) : 1; + } + + // Validate + if (workerProcessCount.ProcessCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(workerProcessCount.ProcessCount), "ProcessCount must be greater than 0."); + } + if (workerProcessCount.ProcessCount > workerProcessCount.MaxProcessCount) + { + throw new ArgumentException($"{nameof(workerProcessCount.ProcessCount)} must not be greater than {nameof(workerProcessCount.MaxProcessCount)}"); + } + if (workerProcessCount.ProcessStartupInterval.Ticks < 0) + { + throw new ArgumentOutOfRangeException($"{nameof(workerProcessCount.ProcessStartupInterval)}", "The TimeSpan must not be negative."); + } + + return workerProcessCount; + } + + private static void GetWorkerDescriptionFromAppSettings(RpcWorkerDescription workerDescription, ImmutableDictionary workerDescriptionOverrides) + { + if (workerDescriptionOverrides.TryGetValue(workerDescription.Language, out var rpcWorkerDescription) && rpcWorkerDescription is not null) + { + workerDescription.DefaultExecutablePath = rpcWorkerDescription.DefaultExecutablePath ?? workerDescription.DefaultExecutablePath; + workerDescription.DefaultRuntimeVersion = rpcWorkerDescription.DefaultRuntimeVersion ?? workerDescription.DefaultRuntimeVersion; + } + } + + private static void AddArgumentsFromAppSettings(RpcWorkerDescription workerDescription, ImmutableDictionary workerDescriptionOverrides) + { + if (workerDescriptionOverrides.TryGetValue(workerDescription.Language, out var rpcWorkerDescription) && rpcWorkerDescription?.Arguments is string[] args && args.Length > 0) + { + ((List)workerDescription.Arguments).AddRange(args); + } + } + + internal static bool ShouldAddWorkerConfig(string workerDescriptionLanguage, bool placeholderModeEnabled, bool multiLanguageWorkerEnvironment, ILogger logger, string workerRuntime) + { + if (placeholderModeEnabled) + { + return true; + } + + if (multiLanguageWorkerEnvironment) + { + logger.LogInformation("Found multi-language runtime environment. Starting WorkerConfig for language: {workerDescriptionLanguage}", workerDescriptionLanguage); + return true; + } + + if (!string.IsNullOrEmpty(workerRuntime)) + { + logger.LogDebug("EnvironmentVariable {functionWorkerRuntimeSettingName}: {workerRuntime}", EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); + if (workerRuntime.Equals(workerDescriptionLanguage, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // After specialization only create worker provider for the language set by FUNCTIONS_WORKER_RUNTIME env variable + logger.LogInformation("{FUNCTIONS_WORKER_RUNTIME} set to {workerRuntime}. Skipping WorkerConfig for language: {workerDescriptionLanguage}", EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime, workerDescriptionLanguage); + return false; + } + + return true; + } + + private static void ReadLanguageWorkerFile(string workerPath, bool placeHolderModeEnabled, ILogger logger, string workerRuntime) + { + if (!placeHolderModeEnabled + || string.IsNullOrWhiteSpace(workerRuntime) + || !File.Exists(workerPath)) + { + return; + } + + // Reads the file to warm up the operating system's file cache. Can run in the background. + _ = Task.Run(() => + { + const int bufferSize = 4096; + var buffer = ArrayPool.Shared.Rent(bufferSize); + + try + { + using var fs = new FileStream( + workerPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize, + FileOptions.SequentialScan); + + while (fs.Read(buffer, 0, bufferSize) > 0) + { + // Do nothing. The goal is to read the file into the OS cache. + } + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error warming up worker file: {filePath}", workerPath); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + }); + } + + internal static (RpcWorkerDescription WorkerDescription, JsonElement WorkerConfig) GetWorkerDescriptionAndConfig( + string workerDirPath, + IWorkerProfileManager profileManager, + ImmutableDictionary workerDescriptionOverrides, + ILogger logger) + { + try + { + var workerConfigPath = Path.Combine(workerDirPath, RpcWorkerConstants.WorkerConfigFileName); + if (!IsWorkerConfigPathValid(workerConfigPath, logger)) + { + return (null, default); + } + + var workerConfig = GetWorkerConfigJsonElement(workerConfigPath, logger); + if (workerConfig.ValueKind == JsonValueKind.Undefined) + { + return (null, default); + } + + var jsonSerializerOptions = JsonSerializerOptionsProvider.CaseInsensitiveJsonSerializerOptions; + var workerDescriptionElement = workerConfig.GetProperty(WorkerConstants.WorkerDescription); + var workerDescription = workerDescriptionElement.Deserialize(jsonSerializerOptions); + workerDescription.WorkerDirectory = workerDirPath; + + // Read the profiles from worker description and load the profile for which the conditions match + if (workerConfig.TryGetProperty(WorkerConstants.WorkerDescriptionProfiles, out var profiles)) + { + List workerDescriptionProfiles = ReadWorkerDescriptionProfiles(profiles, jsonSerializerOptions, profileManager, logger); + if (workerDescriptionProfiles.Count > 0) + { + profileManager.SetWorkerDescriptionProfiles(workerDescriptionProfiles, workerDescription.Language); + profileManager.LoadWorkerDescriptionFromProfiles(workerDescription, out workerDescription); + } + } + + workerDescription.Arguments ??= new List(); + + // Check if any app settings are provided for that language + GetWorkerDescriptionFromAppSettings(workerDescription, workerDescriptionOverrides); + AddArgumentsFromAppSettings(workerDescription, workerDescriptionOverrides); + + // Validate workerDescription + workerDescription.ApplyDefaultsAndValidate(Directory.GetCurrentDirectory(), logger); + + return (workerDescription, workerConfig); + } + catch (Exception ex) when (!ex.IsFatal()) + { + logger.LogError(ex, "Failed to initialize worker provider for: {workerDir}", workerDirPath); + } + + return (null, default); + } + + /// + /// Determines if the worker directory should be skipped based on the current worker runtime and environment settings. + /// + internal static bool ShouldSkipWorkerDirectory(string workerRuntime, string workerDir, bool isMultiLanguageEnv, bool isPlaceholderMode) + { + // After specialization, load worker config only for the specified runtime unless it's a multi-language app. + // Skip worker directories that don't match the current runtime. + return !isMultiLanguageEnv && + !isPlaceholderMode && + !string.IsNullOrWhiteSpace(workerRuntime) && + !workerRuntime.Equals(workerDir, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Checks if the specified worker configuration file exists at the given path. + /// + private static bool IsWorkerConfigPathValid(string workerConfigPath, ILogger logger) + { + if (!File.Exists(workerConfigPath)) + { + logger.LogDebug("Did not find worker config file at: {workerConfigPath}", workerConfigPath); + return false; + } + + logger.LogDebug("Found worker config: {workerConfigPath}", workerConfigPath); + + return true; + } + + /// + /// Determines if the specified worker description is disabled. + /// + internal static bool IsWorkerDescriptionDisabled(RpcWorkerDescription workerDescription, ILogger logger) + { + if (workerDescription.IsDisabled == true) + { + logger.LogInformation("Skipping WorkerConfig for stack: {language} since it is disabled.", workerDescription.Language); + return true; + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationInfo.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationInfo.cs deleted file mode 100644 index f872d2d321..0000000000 --- a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationInfo.cs +++ /dev/null @@ -1,9 +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.Collections.Generic; - -namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration -{ - internal record WorkerConfigurationInfo(string WorkersRootDirPath, IReadOnlyList WorkerConfigPaths); -} diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverBase.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverBase.cs new file mode 100644 index 0000000000..5b11471c74 --- /dev/null +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverBase.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; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using Microsoft.Azure.WebJobs.Script.Diagnostics; +using Microsoft.Azure.WebJobs.Script.Workers.Profiles; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration +{ + internal abstract class WorkerConfigurationResolverBase : IWorkerConfigurationResolver + { + private readonly ILogger _logger; + private readonly IOptionsMonitor _resolverOptions; + private readonly IMetricsLogger _metricsLogger; + private readonly IWorkerProfileManager _profileManager; + private readonly IFileSystem _fileSystem; + private readonly ISystemRuntimeInformation _systemRuntimeInformation; + + public WorkerConfigurationResolverBase(ILoggerFactory loggerFactory, + IMetricsLogger metricsLogger, + IFileSystem fileSystem, + IWorkerProfileManager workerProfileManager, + ISystemRuntimeInformation systemRuntimeInformation, + IOptionsMonitor workerConfigurationResolverOptions) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryWorkerConfig); + _resolverOptions = workerConfigurationResolverOptions ?? throw new ArgumentNullException(nameof(workerConfigurationResolverOptions)); + _metricsLogger = metricsLogger ?? throw new ArgumentNullException(nameof(metricsLogger)); + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _profileManager = workerProfileManager ?? throw new ArgumentNullException(nameof(workerProfileManager)); + _systemRuntimeInformation = systemRuntimeInformation ?? throw new ArgumentNullException(nameof(systemRuntimeInformation)); + ArgumentNullException.ThrowIfNull(_resolverOptions.CurrentValue); + } + + protected ILogger Logger => _logger; + + protected WorkerConfigurationResolverOptions WorkerResolverOptions => _resolverOptions.CurrentValue; + + protected IMetricsLogger MetricsLogger => _metricsLogger; + + protected IWorkerProfileManager ProfileManager => _profileManager; + + protected IFileSystem FileSystem => _fileSystem; + + protected ISystemRuntimeInformation SystemRuntimeInformation => _systemRuntimeInformation; + + public abstract Dictionary GetWorkerConfigs(); + + internal void ResolveWorkerConfigsFromWithinHost(Dictionary availableWorkerRuntimeToConfigMap) + { + // `availableWorkerRuntimeToConfigMap` could be partially filled by DynamicWorkerConfigurationResolver, which searches for worker configs in probing paths. + // This applies to scenarios such as multi-language worker environment and placeholder mode where some worker configs are found in probing paths, while remaining configs will be loaded from the default path within the Host. + ArgumentNullException.ThrowIfNull(availableWorkerRuntimeToConfigMap); + + foreach (var workerPath in _fileSystem.Directory.EnumerateDirectories(WorkerResolverOptions.WorkersRootDirPath)) + { + WorkerConfigurationHelper.AddProvider(WorkerResolverOptions, workerPath, _metricsLogger, _profileManager, _logger, _systemRuntimeInformation, availableWorkerRuntimeToConfigMap); + } + } + } +} diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptions.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptions.cs index 50d1edbda8..362ba24996 100644 --- a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptions.cs +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptions.cs @@ -1,6 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Azure.WebJobs.Hosting; @@ -9,11 +12,72 @@ namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration { public sealed class WorkerConfigurationResolverOptions : IOptionsFormatter { + /// + /// Gets or sets the value of platform release channel. + /// + public string ReleaseChannel { get; set; } + + /// + /// Gets or sets the value of worker runtime. + /// + public string WorkerRuntime { get; set; } + + /// + /// Gets or sets a value indicating whether placeholder mode is enabled. + /// + public bool IsPlaceholderModeEnabled { get; set; } + + /// + /// Gets or sets a value indicating whether it is a multi-language worker environment. + /// + public bool IsMultiLanguageWorkerEnvironment { get; set; } + + /// + /// Gets or sets the list of probing paths for worker resolution. + /// + public IReadOnlyList ProbingPaths { get; set; } + + /// + /// Gets or sets the worker runtimes available for resolution via Hosting configuration. + /// + public ImmutableHashSet WorkersAvailableForResolution { get; set; } + + /// + /// Gets or sets the dictionary containing language workers related overrides in configuration. + /// + public ImmutableDictionary WorkerDescriptionOverrides { get; set; } + + /// + /// Gets or sets the dictionary that contains the versions of language workers to be ignored during probing outside of the Host. + /// Key: worker name (e.g. "node", "python"). Value: set of versions to exclude from consideration. + /// + public ImmutableDictionary> IgnoredWorkerVersions { get; set; } + + /// + /// Gets or sets a value indicating whether dynamic worker resolution is enabled. + /// + public bool IsDynamicWorkerResolutionEnabled { get; set; } + /// /// Gets or sets the workers directory path within the Host or defined by IConfiguration. /// public string WorkersRootDirPath { get; set; } + /// + /// Gets or sets the value of processor cores count. + /// + public int EffectiveCoresCount { get; set; } + + /// + /// Gets or sets the value of worker process count. + /// + public string WorkerProcessCount { get; set; } + + /// + /// Gets or sets the value of function worker runtime version. + /// + public string FunctionsWorkerRuntimeVersion { get; set; } + /// /// Implements the Format method from IOptionsFormatter interface. /// diff --git a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptionsSetup.cs b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptionsSetup.cs index 313402d224..f4fe1151d2 100644 --- a/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptionsSetup.cs +++ b/src/WebJobs.Script/Workers/Rpc/Configuration/WorkerConfigurationResolverOptionsSetup.cs @@ -2,8 +2,13 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.IO.Abstractions; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -13,26 +18,57 @@ namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration { internal sealed class WorkerConfigurationResolverOptionsSetup : IConfigureOptions { + private const string WebHostConfigurationSource = "WebHost"; + private const string JobHostConfigurationSource = "JobHost"; private readonly IConfiguration _configuration; private readonly IScriptHostManager _scriptHostManager; + private readonly IEnvironment _environment; private readonly IFileSystem _fileSystem; + private readonly IOptions _functionsHostingConfigOptions; private readonly ILogger _logger; - public WorkerConfigurationResolverOptionsSetup(ILoggerFactory loggerFactory, IConfiguration configuration, IScriptHostManager scriptHostManager, IFileSystem fileSystem) + public WorkerConfigurationResolverOptionsSetup(ILoggerFactory loggerFactory, + IConfiguration configuration, + IEnvironment environment, + IFileSystem fileSystem, + IScriptHostManager scriptHostManager, + IOptions functionsHostingConfigOptions) { ArgumentNullException.ThrowIfNull(loggerFactory); _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryWorkerConfig); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _scriptHostManager = scriptHostManager ?? throw new ArgumentNullException(nameof(scriptHostManager)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _functionsHostingConfigOptions = functionsHostingConfigOptions ?? throw new ArgumentNullException(nameof(functionsHostingConfigOptions)); + ArgumentNullException.ThrowIfNull(_functionsHostingConfigOptions.Value); } public void Configure(WorkerConfigurationResolverOptions options) { var configuration = GetRequiredConfiguration(); options.WorkersRootDirPath = GetWorkersRootDirPath(configuration); + options.WorkerRuntime = _environment.GetFunctionsWorkerRuntime(); + options.WorkersAvailableForResolution = GetWorkersAvailableForResolution(); + options.IsPlaceholderModeEnabled = _environment.IsPlaceholderModeEnabled(); + options.IsMultiLanguageWorkerEnvironment = _environment.IsMultiLanguageRuntimeEnvironment(); + options.WorkerDescriptionOverrides = GetWorkerDescriptionOverrides(configuration); + options.WorkerProcessCount = _environment.GetEnvironmentVariable(RpcWorkerConstants.FunctionsWorkerProcessCountSettingName); + options.FunctionsWorkerRuntimeVersion = _environment.GetEnvironmentVariable(RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName); + options.EffectiveCoresCount = _environment.GetEffectiveCoresCount(); + options.IsDynamicWorkerResolutionEnabled = IsDynamicWorkerResolutionEnabled(options.WorkerRuntime, options.WorkersAvailableForResolution, options.IsPlaceholderModeEnabled, options.IsMultiLanguageWorkerEnvironment); + + if (options.IsDynamicWorkerResolutionEnabled) + { + options.ReleaseChannel = _environment.GetPlatformReleaseChannel(); + options.ProbingPaths = GetWorkerProbingPaths(configuration); + options.IgnoredWorkerVersions = GetIgnoredWorkerVersions(); + } } + /// + /// Returns the default workers directory path within the Host based on the current assembly location. + /// internal string GetDefaultWorkersDirectory() { var assemblyDir = AppContext.BaseDirectory; @@ -47,6 +83,9 @@ internal string GetDefaultWorkersDirectory() return workersDirPath; } + /// + /// Gets the workers root directory path from configuration, or returns the default workers directory path within the Host if not set. + /// private string GetWorkersRootDirPath(IConfiguration configuration) { if (configuration is not null) @@ -62,41 +101,204 @@ private string GetWorkersRootDirPath(IConfiguration configuration) return GetDefaultWorkersDirectory(); } + /// + /// Combines the base configuration and the latest configuration from the script host manager, if available. + /// private IConfiguration GetRequiredConfiguration() { - string requiredSection = $"{RpcWorkerConstants.LanguageWorkersSectionName}:{WorkerConstants.WorkersDirectorySectionName}"; + LogWorkersDirSectionPresence(_configuration, WebHostConfigurationSource); + var configuration = _configuration; + // Use the latest configuration from the ScriptHostManager if available. + // After specialization, the ScriptHostManager will have the latest IConfiguration reflecting additional configuration entries added during specialization. if (_scriptHostManager is IServiceProvider scriptHostManagerServiceProvider) { var latestConfiguration = scriptHostManagerServiceProvider.GetService(); - var latestConfigValue = GetConfigurationSectionValue(latestConfiguration, nameof(latestConfiguration), requiredSection); - if (!string.IsNullOrEmpty(latestConfigValue)) + if (latestConfiguration is not null) { - return latestConfiguration; + LogWorkersDirSectionPresence(latestConfiguration, JobHostConfigurationSource); + + configuration = new ConfigurationBuilder() + .AddConfiguration(_configuration) + .AddConfiguration(latestConfiguration) + .Build(); } } - string configSectionValue = GetConfigurationSectionValue(_configuration, nameof(_configuration), requiredSection); - if (!string.IsNullOrEmpty(configSectionValue)) + return configuration; + } + + /// + /// Logs a message if the required configuration section is found. + /// + private void LogWorkersDirSectionPresence(IConfiguration configuration, string configurationSource) + { + var configSectionToCheck = ConfigurationPath.Combine(RpcWorkerConstants.LanguageWorkersSectionName, WorkerConstants.WorkersDirectorySectionName); + var section = configuration.GetSection(configSectionToCheck); + + if (!string.IsNullOrEmpty(section.Value)) { - return _configuration; + _logger.LogDebug("Found configuration section '{requiredSection}' in {configurationSource}.", configSectionToCheck, configurationSource); } + } + + /// + /// Retrieves the list of worker probing paths from configuration or uses the default path for Windows environment. + /// + private List GetWorkerProbingPaths(IConfiguration configuration) + { + // If Configuration section is set, read probing paths from configuration. + var probingPathsSection = configuration.GetSection(ConfigurationPath.Combine(RpcWorkerConstants.LanguageWorkersSectionName, RpcWorkerConstants.WorkerProbingPathsSectionName)); + var probingPaths = probingPathsSection.Get>(); + _logger.LogDebug("Worker probing paths specified via configuration: {probingPaths}.", probingPaths); + + probingPaths = probingPaths ?? []; - return null; + if (probingPaths.Count == 0) + { + if (_environment.IsHostedWindowsEnvironment()) + { + // Default worker probing path for Windows + var windowsSiteExtensionsPath = GetWindowsSiteExtensionsPath(); + + if (!string.IsNullOrWhiteSpace(windowsSiteExtensionsPath)) + { + // Example probing path for Windows: "c:\\home\\SiteExtensions\\functionsworkers" + var windowsWorkerProbingPath = Path.Combine(windowsSiteExtensionsPath, RpcWorkerConstants.FunctionsWorkersDirectoryName); + probingPaths.Add(windowsWorkerProbingPath); + _logger.LogDebug("Default worker probing path for Windows: {windowsWorkerProbingPath}.", windowsWorkerProbingPath); + } + } + } + + return probingPaths; + } + + /// + /// Returns the default site extensions path for Windows environment. + /// + private static string GetWindowsSiteExtensionsPath() + { + var assemblyDir = AppContext.BaseDirectory; + + //Move 2 directories up to get to the SiteExtensions directory. Example output: "c:\\home\\SiteExtensions" + return Directory.GetParent(assemblyDir.TrimEnd(Path.DirectorySeparatorChar))?.Parent?.FullName; } - private string GetConfigurationSectionValue(IConfiguration configuration, string configurationSource, string requiredSection) + /// + /// Returns a set of worker runtimes available for dynamic resolution from hosting config. + /// + private ImmutableHashSet GetWorkersAvailableForResolution() => + (_functionsHostingConfigOptions.Value.WorkersAvailableForDynamicResolution ?? string.Empty) + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Converts language workers related configuration sections to a dictionary. + /// Output format: { language: RpcWorkerDescription }. + /// + private static ImmutableDictionary GetWorkerDescriptionOverrides(IConfiguration configuration) { - var section = configuration?.GetSection(requiredSection); + var workerDescriptionsMap = ImmutableDictionary.CreateBuilder(); + var languageWorkersSection = configuration.GetSection(RpcWorkerConstants.LanguageWorkersSectionName); + languageWorkersSection.Bind(workerDescriptionsMap); - if (!string.IsNullOrEmpty(section?.Value)) + // special handling for Arguments which takes a string but internally requires a List. + for (int i = 0; i < workerDescriptionsMap.Keys.Count(); i++) { - _logger.LogTrace("Found configuration section '{requiredSection}' in '{configurationSource}'.", requiredSection, configurationSource); - return section.Value; + var (language, workerDescription) = workerDescriptionsMap.ElementAt(i); + var arguments = languageWorkersSection.GetSection(language).GetValue(WorkerConstants.WorkerDescriptionArguments); + if (!string.IsNullOrEmpty(arguments)) + { + workerDescription.Arguments = RegexHolder.WhiteSpaceRegex().Split(arguments); + workerDescriptionsMap[language] = workerDescription; + } } - return null; + return workerDescriptionsMap.ToImmutable(); } + + /// + /// Gets a value indicating whether dynamic worker resolution is enabled. + /// Users can disable dynamic worker resolution via setting the appropriate feature flag. + /// Worker resolution can be enabled for specific workers at the stamp level via hosting config options. + /// Feature flag takes precedence over hosting config options. + /// + private bool IsDynamicWorkerResolutionEnabled(string workerRuntime, ImmutableHashSet workersAvailableForResolution, bool isPlaceholderModeEnabled, bool isMultiLanguageEnv) + { + if (FeatureFlags.IsEnabled(ScriptConstants.FeatureFlagDisableDynamicWorkerResolution, _environment) || workersAvailableForResolution.Count == 0) + { + return false; + } + + if (!isPlaceholderModeEnabled && !isMultiLanguageEnv && !string.IsNullOrWhiteSpace(workerRuntime)) + { + return workersAvailableForResolution.Contains(workerRuntime); + } + + return true; + } + + /// + /// Returns a dictionary of worker names to sets of ignored versions, parsed from hosting config options. + /// Output format: { worker-name: { hashset of versions to be ignored }}. + /// Sample output: {"java": {"2.19.0", "2.18.0"}, "dotnet-isolated": {"1.0.0"}}. + /// + private ImmutableDictionary> GetIgnoredWorkerVersions() + { + // Example value of ignoredWorkerVersions: "Worker1Name:Version1|Worker1Name:Version2|Worker2Name:Version1|Worker3Name:Version1". + var ignoredWorkerVersions = _functionsHostingConfigOptions.Value.IgnoredWorkerVersions; + + if (string.IsNullOrWhiteSpace(ignoredWorkerVersions)) + { + return ImmutableDictionary>.Empty; + } + + var ignoredVersions = ignoredWorkerVersions.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var ignoredVersionsOut = ImmutableDictionary.CreateBuilder>(StringComparer.OrdinalIgnoreCase); + + foreach (string ignoredVersion in ignoredVersions) + { + string[] workerVersionParts = ignoredVersion.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (workerVersionParts.Length != 2) + { + _logger.LogDebug("Skipping '{ignoredVersion}' due to invalid format for ignored worker version. Expected format is 'WorkerName:Version'.", ignoredVersion); + continue; + } + + var workerName = workerVersionParts[0]; + var version = workerVersionParts[1]; + + if (!Version.TryParse(version, out Version parsedVersion)) + { + _logger.LogDebug("Skipping '{ignoredVersion}' due to invalid version format: '{version}' for worker '{workerName}'.", ignoredVersion, version, workerName); + continue; + } + + if (ignoredVersionsOut.TryGetValue(workerName, out HashSet value)) + { + value.Add(parsedVersion); + ignoredVersionsOut[workerName] = value; + } + else + { + ignoredVersionsOut[workerName] = [parsedVersion]; + } + } + + return ignoredVersionsOut.ToImmutable(); + } + } + + internal static partial class RegexHolder + { +#if NET7_0_OR_GREATER + [GeneratedRegex(@"\s+")] + public static partial Regex WhiteSpaceRegex(); +#else + public static Regex WhiteSpaceRegex() => new Regex(@"\s+"); +#endif } } diff --git a/src/WebJobs.Script/Workers/Rpc/IWorkerConfigurationResolver.cs b/src/WebJobs.Script/Workers/Rpc/IWorkerConfigurationResolver.cs index 517f11f51d..b32799cfa1 100644 --- a/src/WebJobs.Script/Workers/Rpc/IWorkerConfigurationResolver.cs +++ b/src/WebJobs.Script/Workers/Rpc/IWorkerConfigurationResolver.cs @@ -1,9 +1,7 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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 Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; -using Microsoft.Extensions.Options; namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc { @@ -13,8 +11,8 @@ namespace Microsoft.Azure.WebJobs.Script.Workers.Rpc internal interface IWorkerConfigurationResolver { /// - /// Retrieves the worker configuration resolution information which includes the root directory path of workers and worker configuration paths. + /// Retrieves a dictionary of worker configurations, keyed by language name. /// - WorkerConfigurationInfo GetConfigurationInfo(); + Dictionary GetWorkerConfigs(); } } diff --git a/src/WebJobs.Script/Workers/Rpc/RpcWorkerConstants.cs b/src/WebJobs.Script/Workers/Rpc/RpcWorkerConstants.cs index a9fcd89706..1b38144eda 100644 --- a/src/WebJobs.Script/Workers/Rpc/RpcWorkerConstants.cs +++ b/src/WebJobs.Script/Workers/Rpc/RpcWorkerConstants.cs @@ -23,9 +23,12 @@ public static class RpcWorkerConstants public const string CustomHandlerLanguageWorkerName = "custom"; public const string WorkerConfigFileName = "worker.config.json"; public const string DefaultWorkersDirectoryName = "workers"; + public const string FunctionsWorkersDirectoryName = "functionsworkers"; // Section names in host.json or AppSettings public const string LanguageWorkersSectionName = "languageWorkers"; + public const string WorkerProbingPathsSectionName = "probingPaths"; + public const string HostRequirementsSectionName = "hostRequirements"; // Worker description constants public const string WorkerDescriptionLanguage = "language"; @@ -90,6 +93,8 @@ public static class RpcWorkerConstants public const string WorkerIndexingEnabled = "WORKER_INDEXING_ENABLED"; public const string WorkerIndexingDisabledApps = "WORKER_INDEXING_DISABLED_APPS"; + public const string WorkersAvailableForDynamicResolution = "WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION"; + public const string IgnoredWorkerVersions = "IGNORED_WORKER_VERSIONS"; public const string RevertWorkerShutdownBehavior = "REVERT_WORKER_SHUTDOWN_BEHAVIOR"; public const string ShutdownWebhostWorkerChannelsOnHostShutdown = "ShutdownWebhostWorkerChannelsOnHostShutdown"; public const string ThrowOnMissingFunctionsWorkerRuntime = "THROW_ON_MISSING_FUNCTIONS_WORKER_RUNTIME"; diff --git a/src/WebJobs.Script/Workers/Rpc/RpcWorkerDescription.cs b/src/WebJobs.Script/Workers/Rpc/RpcWorkerDescription.cs index 2b5921daed..cde346b844 100644 --- a/src/WebJobs.Script/Workers/Rpc/RpcWorkerDescription.cs +++ b/src/WebJobs.Script/Workers/Rpc/RpcWorkerDescription.cs @@ -183,7 +183,7 @@ private void ResolveDotNetDefaultExecutablePath(ILogger logger) } } - internal void FormatWorkerPathIfNeeded(ISystemRuntimeInformation systemRuntimeInformation, IEnvironment environment, ILogger logger) + internal void FormatWorkerPathIfNeeded(ISystemRuntimeInformation systemRuntimeInformation, string workerRuntime, string version, ILogger logger) { if (string.IsNullOrEmpty(DefaultWorkerPath)) { @@ -192,8 +192,6 @@ internal void FormatWorkerPathIfNeeded(ISystemRuntimeInformation systemRuntimeIn OSPlatform os = systemRuntimeInformation.GetOSPlatform(); Architecture architecture = systemRuntimeInformation.GetOSArchitecture(); - string workerRuntime = environment.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime); - string version = environment.GetEnvironmentVariable(RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName); logger.LogDebug($"EnvironmentVariable {RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName}: {version}"); // Only over-write DefaultRuntimeVersion if workerRuntime matches language for the worker config diff --git a/test/TestWorkers/ProbingPaths/functionsworkers/dotnet-isolated/1.0.0/worker.config.json b/test/TestWorkers/ProbingPaths/functionsworkers/dotnet-isolated/1.0.0/worker.config.json new file mode 100644 index 0000000000..aef262654c --- /dev/null +++ b/test/TestWorkers/ProbingPaths/functionsworkers/dotnet-isolated/1.0.0/worker.config.json @@ -0,0 +1,8 @@ +{ + "description": { + "language": "dotnet-isolated", + "extensions": [ ".dll" ], + "workerIndexing": "true", + "defaultExecutablePath": "worker.config.json" + } +} \ No newline at end of file diff --git a/test/TestWorkers/ProbingPaths/functionsworkers/java/2.18.0/worker.config.json b/test/TestWorkers/ProbingPaths/functionsworkers/java/2.18.0/worker.config.json new file mode 100644 index 0000000000..f38fd2dd93 --- /dev/null +++ b/test/TestWorkers/ProbingPaths/functionsworkers/java/2.18.0/worker.config.json @@ -0,0 +1,10 @@ +{ + "description": { + "language": "java", + "extensions": [".jar"], + "defaultExecutablePath": "%JAVA_HOME%/bin/java", + "defaultWorkerPath": "worker.config.json", + "arguments": [] + }, + "profiles": [] +} \ No newline at end of file diff --git a/test/TestWorkers/ProbingPaths/functionsworkers/java/2.18.1/worker.config.json b/test/TestWorkers/ProbingPaths/functionsworkers/java/2.18.1/worker.config.json new file mode 100644 index 0000000000..13ddd8acf5 --- /dev/null +++ b/test/TestWorkers/ProbingPaths/functionsworkers/java/2.18.1/worker.config.json @@ -0,0 +1,14 @@ +{ + "description": { + "language": "java", + "extensions": [".jar"], + "defaultExecutablePath": "%JAVA_HOME%/bin/java", + "defaultWorkerPath": "azure-functions-java-worker.jar", + "arguments": [] + }, + "hostRequirements": [ + "test-capability1", + "test-capability2" + ], + "profiles": [] +} \ No newline at end of file diff --git a/test/TestWorkers/ProbingPaths/functionsworkers/java/2.19.0/worker.config.json b/test/TestWorkers/ProbingPaths/functionsworkers/java/2.19.0/worker.config.json new file mode 100644 index 0000000000..c50bb3919b --- /dev/null +++ b/test/TestWorkers/ProbingPaths/functionsworkers/java/2.19.0/worker.config.json @@ -0,0 +1,11 @@ +{ + "description": { + "language": "java", + "extensions": [".jar"], + "defaultExecutablePath": "%JAVA_HOME%/bin/java", + "defaultWorkerPath": "worker.config.json", + "arguments": [] + }, + "hostRequirements": [], + "profiles": [] +} \ No newline at end of file diff --git a/test/TestWorkers/ProbingPaths/functionsworkers/node/3.10.1/worker.config.json b/test/TestWorkers/ProbingPaths/functionsworkers/node/3.10.1/worker.config.json new file mode 100644 index 0000000000..564977d92c --- /dev/null +++ b/test/TestWorkers/ProbingPaths/functionsworkers/node/3.10.1/worker.config.json @@ -0,0 +1,14 @@ +{ + "description": { + "language": "node", + "extensions": [".js", ".mjs", ".cjs"], + "defaultExecutablePath": "node", + "defaultWorkerPath": "worker.config.json", + "workerIndexing": "true" + }, + "hostRequirements": [], + "processOptions": { + "initializationTimeout": "00:02:00", + "environmentReloadTimeout": "00:02:00" + } +} diff --git a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/MultiLanguageEndToEndTests.cs b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/MultiLanguageEndToEndTests.cs index f0460dc600..f8395a5a3b 100644 --- a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/MultiLanguageEndToEndTests.cs +++ b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/MultiLanguageEndToEndTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. using System; @@ -7,9 +7,13 @@ using System.IO; using System.Net; using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Azure.WebJobs.Script.Description; using Microsoft.Azure.WebJobs.Script.WebHost.Management; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Azure.WebJobs.Script.Workers.Rpc; +using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; +using Microsoft.Extensions.Configuration; using Moq; using WebJobs.Script.Tests; using Xunit; @@ -117,8 +121,10 @@ public async Task CodelessFunction_CanUse_SingleJavaLanguageProviders() /// /// Runs tests with Node language provider function. /// - [Fact] - public async Task CodelessFunction_CanUse_SingleJavascriptLanguageProviders() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CodelessFunction_CanUse_SingleJavascriptLanguageProviders(bool enableDynamicWorkerResolution) { var sourceFunctionApp = Path.Combine(Environment.CurrentDirectory, "TestScripts", "NoFunction"); var settings = new Dictionary() @@ -138,8 +144,12 @@ public async Task CodelessFunction_CanUse_SingleJavascriptLanguageProviders() var javascriptFunctionProvider = new TestCodelessFunctionProvider(javascriptMetadataList, null); var functions = new[] { "InProcCSFunction", "JavascriptFunction" }; - using (var host = StartLocalHost(baseTestPath, sourceFunctionApp, functions, new List() { cSharpFunctionProvider, javascriptFunctionProvider }, testEnvironment)) + using (var host = StartLocalHost(baseTestPath, sourceFunctionApp, functions, new List() { cSharpFunctionProvider, javascriptFunctionProvider }, testEnvironment, enableDynamicWorkerResolution)) { + var services = host.WebHostServices.GetService(); + var resolverType = enableDynamicWorkerResolution ? typeof(DynamicWorkerConfigurationResolver) : typeof(DefaultWorkerConfigurationResolver); + Assert.True(services.GetType() == resolverType); + var cSharpFunctionKey = await host.GetFunctionSecretAsync("InProcCSFunction"); using var cSharpHttpTriggerResponse = await host.HttpClient.GetAsync($"http://localhost/api/InProcCSFunction?name=Azure&code={cSharpFunctionKey}"); Assert.Equal(HttpStatusCode.OK, cSharpHttpTriggerResponse.StatusCode); @@ -156,8 +166,10 @@ public async Task CodelessFunction_CanUse_SingleJavascriptLanguageProviders() /// /// Runs tests with no language provider function. /// - [Fact] - public async Task CodelessFunction_CanUse_NoLanguageProviders() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CodelessFunction_CanUse_NoLanguageProviders(bool enableDynamicWorkerResolution) { var sourceFunctionApp = Path.Combine(Environment.CurrentDirectory, "TestScripts", "NoFunction"); var settings = new Dictionary() @@ -175,7 +187,7 @@ public async Task CodelessFunction_CanUse_NoLanguageProviders() var cSharpFunctionProvider = new TestCodelessFunctionProvider(cSharpMetadataList, null); var functions = new[] { "InProcCSFunction" }; - using (var host = StartLocalHost(baseTestPath, sourceFunctionApp, functions, new List() { cSharpFunctionProvider }, testEnvironment)) + using (var host = StartLocalHost(baseTestPath, sourceFunctionApp, functions, new List() { cSharpFunctionProvider }, testEnvironment, enableDynamicWorkerResolution)) { var cSharpFunctionKey = await host.GetFunctionSecretAsync("InProcCSFunction"); using var cSharpHttpTriggerResponse = await host.HttpClient.GetAsync($"http://localhost/api/InProcCSFunction?name=Azure&code={cSharpFunctionKey}"); @@ -193,7 +205,7 @@ public async Task CodelessFunction_CanUse_NoLanguageProviders() /// Allowed functions list. /// List of function providers. /// Environment settings. - private TestFunctionHost StartLocalHost(string baseTestPath, string sourceFunctionApp, string[] allowedList, IList providers, IEnvironment testEnvironment) + private TestFunctionHost StartLocalHost(string baseTestPath, string sourceFunctionApp, string[] allowedList, IList providers, IEnvironment testEnvironment, bool enableDynamicWorkerResolution = false) { string appContent = Path.Combine(baseTestPath, "FunctionApp"); string testLogPath = Path.Combine(baseTestPath, "Logs"); @@ -201,6 +213,15 @@ private TestFunctionHost StartLocalHost(string baseTestPath, string sourceFuncti var syncTriggerMock = new Mock(MockBehavior.Strict); syncTriggerMock.Setup(p => p.TrySyncTriggersAsync(It.IsAny())).ReturnsAsync(new TriggersOperationResult { Success = true }); + var inMemorySettings = new Dictionary(); + string workersAvailableForResolution = string.Empty; + + if (enableDynamicWorkerResolution) + { + workersAvailableForResolution = "java|node|dotnet-isolated"; + inMemorySettings["languageWorkers:probingPaths:0"] = Path.GetFullPath("workers"); + } + FileUtility.CopyDirectory(sourceFunctionApp, appContent); var host = new TestFunctionHost(sourceFunctionApp, testLogPath, configureScriptHostWebJobsBuilder: builder => @@ -227,6 +248,11 @@ private TestFunctionHost StartLocalHost(string baseTestPath, string sourceFuncti configureWebHostServices: service => { service.AddSingleton(testEnvironment); + service.Configure(o => o.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, workersAvailableForResolution)); + }, + configureWebHostAppConfiguration: configBuilder => + { + configBuilder.AddInMemoryCollection(inMemorySettings); }); return host; diff --git a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs index ec19b94dbe..727a2b7abe 100644 --- a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs +++ b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs @@ -23,6 +23,7 @@ using Microsoft.Azure.WebJobs.Host.Storage; using Microsoft.Azure.WebJobs.Logging; using Microsoft.Azure.WebJobs.Logging.ApplicationInsights; +using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Azure.WebJobs.Script.Configuration; using Microsoft.Azure.WebJobs.Script.Description; using Microsoft.Azure.WebJobs.Script.Grpc; @@ -93,7 +94,7 @@ public async Task ApplicationInsights_InvocationsContainDifferentOperationIds() // operation id of this request and all host logs would as well. var channel = new TestTelemetryChannel(); - var builder = CreateStandbyHostBuilder("OneSecondTimer", "FunctionExecutionContext") + var builder = CreateStandbyHostBuilder(_loggerProvider, "OneSecondTimer", "FunctionExecutionContext") .ConfigureScriptHostServices(s => { s.AddSingleton(_ => channel); @@ -171,7 +172,7 @@ await TestHelpers.Await(() => [Fact] public async Task Specialization_ThreadUtilization() { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext"); // TODO: https://github.com/Azure/azure-functions-host/issues/4876 using (var testServer = new TestServer(builder)) @@ -232,7 +233,7 @@ public async Task Specialization_ThreadUtilization() [Fact] public async Task Specialization_ResetsSharedLoadContext() { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext"); using (var testServer = new TestServer(builder)) { @@ -263,7 +264,7 @@ public async Task ForNonReadOnlyFileSystem_RestartWorkerForSpecializationAndHotR _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "node"); _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebJobsFeatureFlags, ScriptConstants.FeatureFlagEnableWorkerIndexing); - var builder = CreateStandbyHostBuilder("HttpTriggerNoAuth"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "HttpTriggerNoAuth"); builder.ConfigureAppConfiguration(config => { @@ -337,7 +338,7 @@ public async Task Specialization_RestartsWorkerForNonReadOnlyFileSystem() _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "node"); _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebJobsFeatureFlags, ScriptConstants.FeatureFlagEnableWorkerIndexing); - var builder = CreateStandbyHostBuilder("HttpTriggerNoAuth"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "HttpTriggerNoAuth"); builder.ConfigureAppConfiguration(config => { @@ -380,7 +381,7 @@ public async Task Specialization_UsePlaceholderWorkerforReadOnlyFileSystem() _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "node"); _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebJobsFeatureFlags, ScriptConstants.FeatureFlagEnableWorkerIndexing); - var builder = CreateStandbyHostBuilder("HttpTriggerNoAuth"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "HttpTriggerNoAuth"); string isFileSystemReadOnly = ConfigurationPath.Combine(ConfigurationSectionNames.WebHost, nameof(ScriptApplicationHostOptions.IsFileSystemReadOnly)); builder.ConfigureAppConfiguration(config => @@ -421,7 +422,7 @@ public async Task Specialization_RestartWorkerWithWorkerArguments() _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "node"); _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebJobsFeatureFlags, ScriptConstants.FeatureFlagEnableWorkerIndexing); - var builder = CreateStandbyHostBuilder("HttpTriggerNoAuth"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "HttpTriggerNoAuth"); string isFileSystemReadOnly = ConfigurationPath.Combine(ConfigurationSectionNames.WebHost, nameof(ScriptApplicationHostOptions.IsFileSystemReadOnly)); builder.ConfigureAppConfiguration(config => @@ -463,7 +464,7 @@ public async Task Specialization_RestartWorkerWithWorkerArguments() [Fact] public async Task Specialization_GCMode() { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext"); + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext"); using (var testServer = new TestServer(builder)) { @@ -488,7 +489,7 @@ public async Task Specialization_GCMode() [Fact] public async Task Specialization_ResetsSecretManagerRepository() { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext") + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext") .ConfigureLogging(logging => { logging.AddFilter(null, LogLevel.Debug); @@ -533,7 +534,7 @@ public async Task Specialization_ResetsSecretManagerRepository() [Fact] public async Task StartAsync_SetsCorrectActiveHost_RefreshesLanguageWorkerOptions() { - var builder = CreateStandbyHostBuilder(); + var builder = CreateStandbyHostBuilder(_loggerProvider); await _pauseAfterStandbyHostBuild.WaitAsync(); @@ -591,7 +592,7 @@ public async Task Specialization_LoadWebHookProviderAndRetrieveSecrets() // Add environment variables expected throughout the specialization (similar to how DWAS updates the environment) using (new TestScopedEnvironmentVariable("AzureWebJobsStorage", "")) { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext") + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext") .ConfigureScriptHostWebJobsBuilder(s => { if (!_environment.IsPlaceholderModeEnabled()) @@ -639,7 +640,7 @@ public async Task Specialization_CustomStartupRemovesAzureWebJobsStorage() // Add environment variables expected throughout the specialization (similar to how DWAS updates the environment) using (new TestScopedEnvironmentVariable("AzureWebJobsStorage", "")) { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext") + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext") .ConfigureScriptHostWebJobsBuilder(s => { if (!_environment.IsPlaceholderModeEnabled()) @@ -688,7 +689,7 @@ public async Task Specialization_CustomStartupAddsWebJobsStorage() // No AzureWebJobsStorage set in environment variables (App Settings from portal) using (new TestScopedEnvironmentVariable("AzureWebJobsStorage", "")) { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext") + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext") .ConfigureScriptHostWebJobsBuilder(s => { if (!_environment.IsPlaceholderModeEnabled()) @@ -752,7 +753,7 @@ public async Task Specialization_JobHostInternalStorageOptionsUpdatesWithActiveH using (new TestScopedEnvironmentVariable("AzureFunctionsJobHost__InternalSasBlobContainer", "")) using (new TestScopedEnvironmentVariable("AzureWebJobsStorage", "")) { - var builder = CreateStandbyHostBuilder("FunctionExecutionContext") + var builder = CreateStandbyHostBuilder(_loggerProvider, "FunctionExecutionContext") .ConfigureScriptHostWebJobsBuilder(s => { if (!_environment.IsPlaceholderModeEnabled()) @@ -818,7 +819,7 @@ public async Task Specialization_FlexSku_McpPreview_SetsWorkerRuntimeToCustom(st { { EnvironmentSettingNames.AzureWebsiteSku, websiteSku } }; - var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetCustomHandlerPath, environmentVariables, "SimpleHttpTrigger"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetCustomHandlerPath, _loggerProvider, environmentVariables, "SimpleHttpTrigger"); using var testServer = new TestServer(builder); var client = testServer.CreateClient(); @@ -855,7 +856,7 @@ public async Task Specialization_FlexSku_McpPreview_SetsWorkerRuntimeToCustom(st [Fact] public async Task DotNetIsolated_PlaceholderHit() { - var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, "HttpRequestDataFunction"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, _loggerProvider, "HttpRequestDataFunction"); using var testServer = new TestServer(builder); @@ -897,7 +898,7 @@ public async Task DotNetIsolated_PlaceholderHit() [InlineData("", null)] public async Task ResponseCompressionWorksAfterSpecialization(string acceptEncodingRequestHeaderValue, string expectedContentEncodingResponseHeaderValue) { - var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, "HttpRequestDataFunction"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, _loggerProvider, "HttpRequestDataFunction"); using var testServer = new TestServer(builder); @@ -939,7 +940,7 @@ public async Task Specialization_DotnetIsolatedApp_MissingAzureFunctionsDir_Logs 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); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(path, _loggerProvider); using var testServer = new TestServer(builder); @@ -965,12 +966,175 @@ await TestHelpers.Await(() => }); } + [Fact] + public async Task Specialization_DynamicResolution_FallbackPath_Logs() + { + var loggerProvider = new TestLoggerProvider(); + + 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, loggerProvider); + + string fallbackPath = Path.Combine(Directory.GetCurrentDirectory(), "workers"); + + builder.ConfigureServices(services => + { + services.Configure(o => o.Features["WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION"] = "node"); + }); + + using var testServer = new TestServer(builder); + + var standbyManager = testServer.Services.GetService(); + Assert.NotNull(standbyManager); + + _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteContainerReady, "1"); + _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "node"); + _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "0"); + + var logs = loggerProvider.GetAllLogMessages().Select(p => p.FormattedMessage); + + Assert.Contains("Placeholder mode is enabled: True", logs); + + var nodeLog = logs.FirstOrDefault(p => p.Contains("Added WorkerConfig for language: node with worker path:") && p.Contains("workers\\node")); + Assert.True(nodeLog.Any()); + + var javaLog = logs.FirstOrDefault(p => p.Contains("Added WorkerConfig for language: java with worker path:") && p.Contains("workers\\java")); + Assert.True(javaLog.Any()); + + var probingLog = logs.FirstOrDefault(p => p.Contains("Worker probing paths set to:")); + Assert.True(probingLog.Any()); + + var fallbackLog = logs.FirstOrDefault(p => p.Contains("Searching for worker configs in the fallback directory:")); + Assert.True(fallbackLog.Any()); + + loggerProvider.ClearAllLogMessages(); + + await standbyManager.SpecializeHostAsync(); + + // Assert: Verify that the host has specialized + var scriptHostManager = testServer.Services.GetService(); + Assert.NotNull(scriptHostManager); + Assert.Equal(ScriptHostState.Running, scriptHostManager.State); + + var newLogs = loggerProvider.GetAllLogMessages().Select(p => p.FormattedMessage); + + Assert.Contains("Completed language worker channel specialization", newLogs); + + var newNodeLog = newLogs.FirstOrDefault(p => p.Contains("Added WorkerConfig for language: node with worker path:") && p.Contains("workers")); + Assert.True(newNodeLog.Any()); + + var newJavaLog = newLogs.FirstOrDefault(p => p.Contains("Added WorkerConfig for language: java with worker path:")); + Assert.Null(newJavaLog); + + probingLog = logs.FirstOrDefault(p => p.Contains("Worker probing paths set to:")); + Assert.True(probingLog.Any()); + + fallbackLog = logs.FirstOrDefault(p => p.Contains("Searching for worker configs in the fallback directory:")); + Assert.True(fallbackLog.Any()); + + } + + [Fact] + public void Specialization_DynamicResolution_Logs() + { + var loggerProvider = new TestLoggerProvider(); + + Guid guid = Guid.NewGuid(); + string path = "test-path" + guid.ToString(); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + Guid guid2 = Guid.NewGuid(); + string workerPath = "worker-path" + guid2.ToString(); + + if (!Directory.Exists(workerPath)) + { + Directory.CreateDirectory(workerPath); + } + + string subdir = Path.Combine(workerPath, "decoupledWorkers", "node", "1.0.0"); + + if (!Directory.Exists(subdir)) + { + Directory.CreateDirectory(subdir); + string workerJson = @"{ + ""description"": { + ""language"": ""node"", + ""extensions"": ["".js"", "".mjs"", "".cjs""], + ""defaultExecutablePath"": ""node"", + ""defaultWorkerPath"": ""worker.config.json"", + ""workerIndexing"": ""true"" + }, + ""hostRequirements"": [] + }"; + + File.WriteAllText(Path.Combine(subdir, "worker.config.json"), workerJson); + } + + 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, loggerProvider); + + string fallbackPath = Path.Combine(Directory.GetCurrentDirectory(), "workers"); + + var inMemorySettings = new Dictionary(); + inMemorySettings["languageWorkers:probingPaths:0"] = Path.Combine(workerPath, "decoupledWorkers"); + + builder.ConfigureServices(services => + { + services.Configure(o => o.Features["WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION"] = "node"); + }); + + builder.ConfigureAppConfiguration(c => + { + c.AddInMemoryCollection(inMemorySettings); + }); + + using var testServer = new TestServer(builder); + + var standbyManager = testServer.Services.GetService(); + Assert.NotNull(standbyManager); + + _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteContainerReady, "1"); + _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "node"); + _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "0"); + + var logs = loggerProvider.GetAllLogMessages().Select(p => p.FormattedMessage); + + Assert.Contains("Placeholder mode is enabled: True", logs); + + var nodeLog = logs.FirstOrDefault(p => p.Contains("Added WorkerConfig for language: node with worker path:") && p.Contains("decoupledWorkers\\node")); + Assert.True(nodeLog.Any()); + + var javaLog = logs.FirstOrDefault(p => p.Contains("Added WorkerConfig for language: java with worker path:") && p.Contains("workers\\java")); + Assert.True(javaLog.Any()); + + var probingLog = logs.FirstOrDefault(p => p.Contains("Worker probing paths set to:")); + Assert.True(probingLog.Any()); + + var fallbackLog = logs.FirstOrDefault(p => p.Contains("Searching for worker configs in the fallback directory:")); + Assert.True(fallbackLog.Any()); + } + [Fact] public async Task DotNetIsolated_PlaceholderHit_WithProxies() { // This test ensures that capabilities are correctly applied in EnvironmentReload during // specialization - var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, "HttpRequestFunction"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, _loggerProvider,"HttpRequestFunction"); using var testServer = new TestServer(builder); @@ -1104,7 +1268,7 @@ public async Task SpecializedSite_StopsHostBeforeWorker() await queue.CreateIfNotExistsAsync(); await queue.ClearAsync(); - var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, "HttpRequestDataFunction", "QueueFunction"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, _loggerProvider, "HttpRequestDataFunction", "QueueFunction"); using var testServer = new TestServer(builder); @@ -1180,7 +1344,7 @@ public async Task Specialization_Writes_WorkerStartupLogs() s.AddSingleton(testLoggerProvider); }; - var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, "HttpRequestDataFunction", "QueueFunction"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(_dotnetIsolated60Path, _loggerProvider, "HttpRequestDataFunction", "QueueFunction"); var storageValue = TestHelpers.GetTestConfiguration().GetWebJobsConnectionString("AzureWebJobsStorage"); using var testServer = new TestServer(builder); @@ -1219,7 +1383,7 @@ public async Task Specialization_Writes_WorkerStartupLogs() private async Task DotNetIsolatedPlaceholderMiss(string scriptRootPath, Action additionalSpecializedSetup = null) { - var builder = InitializeDotNetIsolatedPlaceholderBuilder(scriptRootPath, "HttpRequestDataFunction"); + var builder = InitializeDotNetIsolatedPlaceholderBuilder(scriptRootPath, _loggerProvider, "HttpRequestDataFunction"); // remove WEBSITE_USE_PLACEHOLDER_DOTNETISOLATED _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteUsePlaceholderDotNetIsolated, null); @@ -1267,12 +1431,12 @@ private async Task DotNetIsolatedPlaceholderMiss(string scriptRootPath, Action a } } - private IWebHostBuilder InitializeDotNetIsolatedPlaceholderBuilder(string scriptRootPath, params string[] functions) + private IWebHostBuilder InitializeDotNetIsolatedPlaceholderBuilder(string scriptRootPath, TestLoggerProvider testLoggerProvider, params string[] functions) { - return InitializeDotNetIsolatedPlaceholderBuilder(scriptRootPath, null, functions); + return InitializeDotNetIsolatedPlaceholderBuilder(scriptRootPath, testLoggerProvider, null, functions); } - private IWebHostBuilder InitializeDotNetIsolatedPlaceholderBuilder(string scriptRootPath, Dictionary environmentVariables, params string[] functions) + private IWebHostBuilder InitializeDotNetIsolatedPlaceholderBuilder(string scriptRootPath, TestLoggerProvider testLoggerProvider, Dictionary environmentVariables, params string[] functions) { _environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "dotnet-isolated"); _environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteUsePlaceholderDotNetIsolated, "1"); @@ -1287,7 +1451,7 @@ private IWebHostBuilder InitializeDotNetIsolatedPlaceholderBuilder(string script } } - var builder = CreateStandbyHostBuilder(functions); + var builder = CreateStandbyHostBuilder(testLoggerProvider, functions); builder.ConfigureAppConfiguration(config => { @@ -1300,14 +1464,16 @@ private IWebHostBuilder InitializeDotNetIsolatedPlaceholderBuilder(string script return builder; } - private IWebHostBuilder CreateStandbyHostBuilder(params string[] functions) + private IWebHostBuilder CreateStandbyHostBuilder(TestLoggerProvider loggerProvider, params string[] functions) { + loggerProvider = loggerProvider ?? _loggerProvider; var builder = Program.CreateWebHostBuilder() .ConfigureLogging(b => { - b.AddProvider(_loggerProvider); + b.AddProvider(loggerProvider); b.AddFilter("Microsoft.Azure.WebJobs", LogLevel.Debug); b.AddFilter("Worker", LogLevel.Debug); + b.AddFilter("Host.LanguageWorkerConfig", LogLevel.Trace); }) .ConfigureAppConfiguration(c => { @@ -1330,7 +1496,7 @@ private IWebHostBuilder CreateStandbyHostBuilder(params string[] functions) { s.AddLogging(logging => { - logging.AddProvider(_loggerProvider); + logging.AddProvider(loggerProvider); }); s.PostConfigure(o => diff --git a/test/WebJobs.Script.Tests.Shared/TestHostBuilderExtensions.cs b/test/WebJobs.Script.Tests.Shared/TestHostBuilderExtensions.cs index 971a58058d..2c9338f7fe 100644 --- a/test/WebJobs.Script.Tests.Shared/TestHostBuilderExtensions.cs +++ b/test/WebJobs.Script.Tests.Shared/TestHostBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. using System; @@ -84,6 +84,7 @@ public static IHostBuilder ConfigureDefaultTestWebScriptHost(this IHostBuilder b services.AddSingleton(); services.AddSingleton(); services.AddSingleton(FileUtility.Instance); + services.AddSingleton(SystemRuntimeInformation.Instance); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(metricsLogger); diff --git a/test/WebJobs.Script.Tests/Configuration/FunctionsHostingConfigOptionsTest.cs b/test/WebJobs.Script.Tests/Configuration/FunctionsHostingConfigOptionsTest.cs index 95c38f10a6..7087bb7e55 100644 --- a/test/WebJobs.Script.Tests/Configuration/FunctionsHostingConfigOptionsTest.cs +++ b/test/WebJobs.Script.Tests/Configuration/FunctionsHostingConfigOptionsTest.cs @@ -65,6 +65,17 @@ public static IEnumerable PropertyValues yield return [nameof(FunctionsHostingConfigOptions.IsDotNetInProcDisabled), "DotNetInProcDisabled=1", true]; yield return [nameof(FunctionsHostingConfigOptions.IsDotNetInProcDisabled), "DotNetInProcDisabled=0", false]; + yield return [nameof(FunctionsHostingConfigOptions.WorkersAvailableForDynamicResolution), "WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION=java|node", "java|node"]; + yield return [nameof(FunctionsHostingConfigOptions.WorkersAvailableForDynamicResolution), "WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION=java", "java"]; + yield return [nameof(FunctionsHostingConfigOptions.WorkersAvailableForDynamicResolution), "WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION=java|dotnet-isolated|node", "java|dotnet-isolated|node"]; + yield return [nameof(FunctionsHostingConfigOptions.WorkersAvailableForDynamicResolution), "WORKERS_AVAILABLE_FOR_DYNAMIC_RESOLUTION=", string.Empty]; + + yield return [nameof(FunctionsHostingConfigOptions.IgnoredWorkerVersions), "IGNORED_WORKER_VERSIONS=java:2.0.0|node:2.1.0", "java:2.0.0|node:2.1.0"]; + yield return [nameof(FunctionsHostingConfigOptions.IgnoredWorkerVersions), "IGNORED_WORKER_VERSIONS=java", "java"]; + yield return [nameof(FunctionsHostingConfigOptions.IgnoredWorkerVersions), "IGNORED_WORKER_VERSIONS=java:2.0.0|dotnet-isolated:2.1.0", "java:2.0.0|dotnet-isolated:2.1.0"]; + yield return [nameof(FunctionsHostingConfigOptions.IgnoredWorkerVersions), "IGNORED_WORKER_VERSIONS=", string.Empty]; + yield return [nameof(FunctionsHostingConfigOptions.IgnoredWorkerVersions), "IGNORED_WORKER_VERSIONS=:|:", ":|:"]; + yield return [nameof(FunctionsHostingConfigOptions.IsTestDataSuppressionEnabled), "EnableTestDataSuppression=1", true]; #pragma warning restore SA1011 // Closing square brackets should be spaced correctly diff --git a/test/WebJobs.Script.Tests/Configuration/LanguageWorkerOptionsSetupTests.cs b/test/WebJobs.Script.Tests/Configuration/LanguageWorkerOptionsSetupTests.cs index b851ff5622..1ccaf949b1 100644 --- a/test/WebJobs.Script.Tests/Configuration/LanguageWorkerOptionsSetupTests.cs +++ b/test/WebJobs.Script.Tests/Configuration/LanguageWorkerOptionsSetupTests.cs @@ -2,12 +2,18 @@ // 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.IO.Abstractions; +using System.Linq; +using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Azure.WebJobs.Script.Workers.Profiles; using Microsoft.Azure.WebJobs.Script.Workers.Rpc; using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.WebJobs.Script.Tests; using Moq; using Xunit; @@ -15,6 +21,14 @@ namespace Microsoft.Azure.WebJobs.Script.Tests.Configuration { public class LanguageWorkerOptionsSetupTests { + private readonly string _probingPath1 = Path.GetFullPath(Path.Combine("..", "..", "..", "..", "test", "TestWorkers", "ProbingPaths", "functionsworkers")); + private readonly string _fallbackPath = Path.GetFullPath("workers"); + + public LanguageWorkerOptionsSetupTests() + { + EnvironmentExtensions.ClearCache(); + } + [Theory] [InlineData("DotNet")] [InlineData("dotnet")] @@ -58,9 +72,9 @@ public void LanguageWorkerOptions_Expected_ListOfConfigs(string workerRuntime) var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(configuration, testEnvironment, testScriptHostManager.Object, null); - var resolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); + var resolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); - var setup = new LanguageWorkerOptionsSetup(configuration, loggerFactory, testEnvironment, testMetricLogger, testProfileManager.Object, testScriptHostManager.Object, resolver); + var setup = new LanguageWorkerOptionsSetup(configuration, loggerFactory, testEnvironment, testMetricLogger, testProfileManager.Object, testScriptHostManager.Object, resolver, optionsMonitor); var options = new LanguageWorkerOptions(); setup.Configure(options); @@ -78,5 +92,176 @@ public void LanguageWorkerOptions_Expected_ListOfConfigs(string workerRuntime) Assert.Equal(1, options.WorkerConfigs.Count); } } + + [Theory] + [InlineData("java", "java", "LATEST", "2.19.0")] + [InlineData("java", "java", "STANDARD", "2.18.0")] + [InlineData("node", "node", "LATEST", "3.10.1")] + [InlineData("node", "java|node", "STANDARD", "3.10.1")] + [InlineData("java", "java", "EXTENDED", "2.18.0")] + [InlineData("node", "java|node", "EXTENDED", "3.10.1")] + [InlineData("java", "java", " ", "2.19.0")] + [InlineData("java", "java", null, "2.19.0")] + public void LanguageWorkerOptions_EnabledWorkerResolution_ReturnsListOfConfigs(string workerRuntime, string hostingOptionsSetting, string releaseChannel, string expectedVersion) + { + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testEnvironment = new TestEnvironment(); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); + var testScriptHostManager = new Mock(); + + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel, releaseChannel); + + var probingPaths = new List() { _probingPath1, string.Empty, "path-not-exists" }; + var configuration = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, hostingOptionsSetting); + + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(configuration, testEnvironment, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + + var resolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + + var setup = new LanguageWorkerOptionsSetup(configuration, loggerFactory, testEnvironment, testMetricLogger, testProfileManager.Object, testScriptHostManager.Object, resolver, optionsMonitor); + var options = new LanguageWorkerOptions(); + + setup.Configure(options); + + Assert.Equal(1, options.WorkerConfigs.Count); + Assert.True(options.WorkerConfigs.First().Arguments.WorkerPath.Contains(expectedVersion)); + + var logs = loggerProvider.GetAllLogMessages(); + + string path = Path.Combine(_probingPath1, workerRuntime, expectedVersion); + string expectedLog = $"Added WorkerConfig for language: {workerRuntime} with worker path: {path}"; + Assert.True(logs.Any(l => l.FormattedMessage.Contains(expectedLog))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker probing paths set to:"))); + Assert.False(logs.Any(l => l.FormattedMessage.Contains("Searching for worker configs in the fallback directory"))); + } + + [Theory] + [InlineData("java", "java", "LATEST")] + [InlineData("java", "java", "STANDARD")] + [InlineData("node", "node", "LATEST")] + [InlineData("node", "java|node", "STANDARD")] + public void LanguageWorkerOptions_DynamicResolver_NoProbingPaths_ReturnsListOfConfigs(string workerRuntime, string hostingOptionsSetting, string releaseChannel) + { + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testEnvironment = new TestEnvironment(); + var testMetricLogger = new TestMetricsLogger(); + var configuration = new ConfigurationBuilder().Add(new ScriptEnvironmentVariablesConfigurationSource()).Build(); + var testProfileManager = new Mock(); + var testScriptHostManager = new Mock(); + + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel, releaseChannel); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, hostingOptionsSetting); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(configuration, testEnvironment, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + + var resolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + + var setup = new LanguageWorkerOptionsSetup(configuration, loggerFactory, testEnvironment, testMetricLogger, testProfileManager.Object, testScriptHostManager.Object, resolver, optionsMonitor); + var options = new LanguageWorkerOptions(); + + setup.Configure(options); + + Assert.Equal(1, options.WorkerConfigs.Count); + + var logs = loggerProvider.GetAllLogMessages(); + + string path = Path.Combine(_fallbackPath, workerRuntime); + string expectedLog = $"Added WorkerConfig for language: {workerRuntime} with worker path: {path}"; + Assert.True(logs.Any(l => l.FormattedMessage.Contains(expectedLog))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker probing paths set to:"))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Searching for worker configs in the fallback directory"))); + } + + [Theory] + [InlineData("java", null, "LATEST")] + [InlineData("java", "", "STANDARD")] + [InlineData("node", " ", "LATEST")] + public void LanguageWorkerOptions_DefaultResolver_ListOfConfigs(string workerRuntime, string hostingOptionsSetting, string releaseChannel) + { + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testEnvironment = new TestEnvironment(); + var testMetricLogger = new TestMetricsLogger(); + var configuration = new ConfigurationBuilder().Add(new ScriptEnvironmentVariablesConfigurationSource()).Build(); + var testProfileManager = new Mock(); + var testScriptHostManager = new Mock(); + + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel, releaseChannel); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, hostingOptionsSetting); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(configuration, testEnvironment, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + + var resolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + + LanguageWorkerOptionsSetup setup = new LanguageWorkerOptionsSetup(configuration, loggerFactory, testEnvironment, testMetricLogger, testProfileManager.Object, testScriptHostManager.Object, resolver, optionsMonitor); + LanguageWorkerOptions options = new LanguageWorkerOptions(); + + setup.Configure(options); + + Assert.Equal(1, options.WorkerConfigs.Count); + + var logs = loggerProvider.GetAllLogMessages(); + + string path = Path.Combine(_fallbackPath, workerRuntime); + string expectedLog = $"Added WorkerConfig for language: {workerRuntime} with worker path: {path}"; + Assert.True(logs.Any(l => l.FormattedMessage.Contains(expectedLog))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Workers Directory set to:"))); + } + + [Theory] + [InlineData("java")] + [InlineData("node")] + public void LanguageWorkerOptions_DynamicResolver_FallbackPath_NullHostingConfig(string workerRuntime) + { + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testEnvironment = new TestEnvironment(); + var testMetricLogger = new TestMetricsLogger(); + var configuration = new ConfigurationBuilder().Add(new ScriptEnvironmentVariablesConfigurationSource()).Build(); + var testProfileManager = new Mock(); + var testScriptHostManager = new Mock(); + + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, workerRuntime); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(configuration, testEnvironment, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + + var resolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + + LanguageWorkerOptionsSetup setup = new LanguageWorkerOptionsSetup(configuration, loggerFactory, testEnvironment, testMetricLogger, testProfileManager.Object, testScriptHostManager.Object, resolver, optionsMonitor); + LanguageWorkerOptions options = new LanguageWorkerOptions(); + + setup.Configure(options); + + Assert.Equal(1, options.WorkerConfigs.Count); + + var logs = loggerProvider.GetAllLogMessages(); + + string path = Path.Combine(_fallbackPath, workerRuntime); + string expectedLog = $"Added WorkerConfig for language: {workerRuntime} with worker path: {path}"; + Assert.True(logs.Any(l => l.FormattedMessage.Contains(expectedLog))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker probing paths set to:"))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Searching for worker configs in the fallback directory"))); + } } } diff --git a/test/WebJobs.Script.Tests/Workers/Rpc/DynamicWorkerConfigurationResolverTests.cs b/test/WebJobs.Script.Tests/Workers/Rpc/DynamicWorkerConfigurationResolverTests.cs new file mode 100644 index 0000000000..1fedbe86cb --- /dev/null +++ b/test/WebJobs.Script.Tests/Workers/Rpc/DynamicWorkerConfigurationResolverTests.cs @@ -0,0 +1,321 @@ +// 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.IO.Abstractions; +using System.Linq; +using Microsoft.Azure.WebJobs.Script.Config; +using Microsoft.Azure.WebJobs.Script.Workers; +using Microsoft.Azure.WebJobs.Script.Workers.Rpc; +using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.WebJobs.Script.Tests; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Workers.Rpc +{ + public class DynamicWorkerConfigurationResolverTests + { + private readonly string _probingPath1 = Path.GetFullPath("..\\..\\..\\..\\test\\TestWorkers\\ProbingPaths\\functionsworkers\\"); + private readonly string _fallbackPath = Path.GetFullPath("workers"); + + public DynamicWorkerConfigurationResolverTests() + { + EnvironmentExtensions.ClearCache(); + } + + [Theory] + [InlineData("LATEST", "java\\2.19.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("STANDARD", "java\\2.18.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("EXTENDED", "java\\2.18.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("laTest", "java\\2.19.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("abc", "java\\2.19.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("Standard", "java\\2.18.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + public void GetWorkerConfigs_MultiLanguageWorker_ReturnsExpectedConfigs(string releaseChannel, string java, string node, string powershell, string dotnetIsolated, string python) + { + // Arrange + var probingPaths = new List() { _probingPath1, string.Empty, "path-not-exists" }; + var fileSystem = new FileSystem(); + + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + var testMetricLogger = new TestMetricsLogger(); + + var mockEnvironment = new Mock(); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel)).Returns(releaseChannel); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AppKind)).Returns(ScriptConstants.WorkFlowAppKind); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime)).Returns((string)null); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode)).Returns("1"); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteSku)).Returns("Windows"); + + var config = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, mockEnvironment.Object); + var testScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node"); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, mockEnvironment.Object, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + + // Act + var workerConfigurationResolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, fileSystem, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + + var result = workerConfigurationResolver.GetWorkerConfigs(); + + // Assert + Assert.Equal(result.Count, 5); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_probingPath1, java)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_probingPath1, node)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, powershell)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, dotnetIsolated)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, python)))); + + var logs = loggerProvider.GetAllLogMessages(); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker probing paths set to:"))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker configuration at ") && l.FormattedMessage.Contains("\\ProbingPaths\\functionsworkers\\java\\2.19.0' specifies host requirements []."))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker configuration at ") && l.FormattedMessage.Contains("\\ProbingPaths\\functionsworkers\\node\\3.10.1' specifies host requirements []."))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker probing path directory does not exist: path-not-exists."))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Searching for worker configs in the fallback directory"))); + } + + [Theory] + [InlineData("LATEST", "java", "node", "powershell", "dotnet-isolated", "python")] + [InlineData("STANDARD", "java", "node", "powershell", "dotnet-isolated", "python")] + public void GetWorkerConfigs_MultiLanguageWorker_MalformedProbingPath_ReturnsExpectedConfigs(string releaseChannel, string java, string node, string powershell, string dotnetIsolated, string python) + { + // Arrange + var probingPaths = new List() { _fallbackPath }; + var fileSystem = new FileSystem(); + + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var mockEnvironment = new Mock(); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel)).Returns(releaseChannel); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AppKind)).Returns(ScriptConstants.WorkFlowAppKind); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime)).Returns((string)null); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode)).Returns("1"); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteSku)).Returns("Windows"); + + var config = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, mockEnvironment.Object); + var testScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node"); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, mockEnvironment.Object, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var testMetricLogger = new TestMetricsLogger(); + + // Act + var workerConfigurationResolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, fileSystem, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + + var result = workerConfigurationResolver.GetWorkerConfigs(); + + // Assert + Assert.Equal(result.Count, 5); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, java)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, node)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, powershell)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, dotnetIsolated)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, python)))); + + var logs = loggerProvider.GetAllLogMessages(); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Worker probing paths set to:"))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Failed to parse worker version"))); + Assert.True(logs.Any(l => l.FormattedMessage.Contains("Searching for worker configs in the fallback directory"))); + } + + [Theory] + [InlineData(null, "LATEST")] + [InlineData(null, "STANDARD")] + [InlineData("Empty", "LATEST")] + [InlineData("Empty", "abc")] + public void GetWorkerConfigs_MultiLanguageWorker_NullOREmptyProbingPath_ReturnsExpectedConfigs(string probingPathValue, string releaseChannel) + { + // Arrange + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var mockEnvironment = new Mock(); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel)).Returns(releaseChannel); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AppKind)).Returns(ScriptConstants.WorkFlowAppKind); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime)).Returns((string)null); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode)).Returns("1"); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteSku)).Returns("Windows"); + + List probingPaths = null; + + if (probingPathValue == "Empty") + { + probingPaths = new List(); + } + + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, mockEnvironment.Object); + var config = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + var fileSystem = new FileSystem(); + + var testScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node|powershell"); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, mockEnvironment.Object, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var testMetricLogger = new TestMetricsLogger(); + + // Act + var workerConfigurationResolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, fileSystem, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + + var result = workerConfigurationResolver.GetWorkerConfigs(); + + // Assert + Assert.Equal(result.Count, 5); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, "java")))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, "node")))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, "powershell")))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, "dotnet-isolated")))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, "python")))); + } + + [Theory] + [InlineData(null, "LATEST", "java")] + [InlineData(null, "STANDARD", "java")] + [InlineData("Empty", "LATEST", "java")] + [InlineData("Empty", "STANDARD", "java")] + [InlineData(null, "STANDARD", "node")] + [InlineData("Empty", "LATEST", "node")] + [InlineData(null, "STANDARD", "powershell")] + [InlineData("Empty", "LATEST", "powershell")] + public void GetWorkerConfigs_NullOREmptyProbingPath_ReturnsExpectedConfigs(string probingPathValue, string releaseChannel, string languageWorker) + { + // Arrange + var mockEnv = new Mock(); + mockEnv.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime)).Returns(languageWorker); + mockEnv.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel)).Returns(releaseChannel); + mockEnv.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteSku)).Returns("Windows"); + + List probingPaths = null; + + if (probingPathValue == "Empty") + { + probingPaths = new List(); + } + + var config = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, mockEnv.Object); + var mockConfig = new Mock(); + + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node|powershell"); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, mockEnv.Object, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var testMetricLogger = new TestMetricsLogger(); + + // Act + var workerConfigurationResolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + + var result = workerConfigurationResolver.GetWorkerConfigs(); + + // Assert + Assert.Equal(result.Count, 1); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, languageWorker)))); + } + + [Theory] + [InlineData("LATEST", "java:2.19.0", "java\\2.18.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("LATEST", "java:2.19.0|python:4.1.0", "java\\2.18.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + [InlineData("LATEST", "java:xyz|node:a.b.c", "java\\2.19.0", "node\\3.10.1", "powershell", "dotnet-isolated", "python")] + public void GetWorkerConfigs_MultiLang_IgnoredVersion_ReturnsExpectedConfigs(string releaseChannel, string setting, string java, string node, string powershell, string dotnetIsolated, string python) + { + // Arrange + var mockEnvironment = new Mock(); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel)).Returns(releaseChannel); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AppKind)).Returns(ScriptConstants.WorkFlowAppKind); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime)).Returns((string)null); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode)).Returns("1"); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteSku)).Returns("Windows"); + + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, mockEnvironment.Object); + + var probingPaths = new List() { _probingPath1, string.Empty, "path-not-exists" }; + var config = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node|powershell"); + hostingOptions.Features.Add(RpcWorkerConstants.IgnoredWorkerVersions, setting); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, mockEnvironment.Object, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var testMetricLogger = new TestMetricsLogger(); + + var workerConfigurationResolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + + var result = workerConfigurationResolver.GetWorkerConfigs(); + + // Assert + Assert.Equal(result.Count, 5); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_probingPath1, java)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_probingPath1, node)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, powershell)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, dotnetIsolated)))); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, python)))); + } + + [Theory] + [InlineData("java:2.18.0|java:2.19.0", "java")] + public void GetWorkerConfigs_IgnoredVersion_ReturnsExpectedConfigs(string setting, string workerRuntime) + { + // Arrange + var mockEnvironment = new Mock(); + mockEnvironment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime)).Returns(workerRuntime); + + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, mockEnvironment.Object); + + var probingPaths = new List() { _probingPath1, string.Empty, "path-not-exists" }; + var config = WorkerConfigurationResolverTestsHelper.GetConfigurationWithProbingPaths(probingPaths); + + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var testScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node|powershell"); + hostingOptions.Features.Add(RpcWorkerConstants.IgnoredWorkerVersions, setting); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, mockEnvironment.Object, testScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var testMetricLogger = new TestMetricsLogger(); + + var workerConfigurationResolver = new DynamicWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + + var result = workerConfigurationResolver.GetWorkerConfigs(); + + // Assert + Assert.Equal(result.Count, 1); + Assert.True(result.Any(r => r.Value.Description.DefaultWorkerPath.Contains(Path.Combine(_fallbackPath, workerRuntime)))); + } + } +} \ No newline at end of file diff --git a/test/WebJobs.Script.Tests/Workers/Rpc/RpcFunctionInvocationDispatcherTests.cs b/test/WebJobs.Script.Tests/Workers/Rpc/RpcFunctionInvocationDispatcherTests.cs index 95aec57d2b..01f4af3be1 100644 --- a/test/WebJobs.Script.Tests/Workers/Rpc/RpcFunctionInvocationDispatcherTests.cs +++ b/test/WebJobs.Script.Tests/Workers/Rpc/RpcFunctionInvocationDispatcherTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. using System; @@ -41,6 +41,7 @@ public RpcFunctionInvocationDispatcherTests() _testLoggerFactory.AddProvider(_testLoggerProvider); _testLogger = _testLoggerProvider.CreateLogger("FunctionDispatcherTests"); + EnvironmentExtensions.ClearCache(); } [Fact] diff --git a/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigFactoryTests.cs b/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigFactoryTests.cs index 115d26628c..dc14a26123 100644 --- a/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigFactoryTests.cs +++ b/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigFactoryTests.cs @@ -14,6 +14,7 @@ using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.WebJobs.Script.Tests; using Moq; using Xunit; @@ -31,6 +32,7 @@ public RpcWorkerConfigFactoryTests() _testEnvironment = new TestEnvironment(); var workerProfileLogger = new TestLogger(); _testWorkerProfileManager = new WorkerProfileManager(workerProfileLogger, _testEnvironment); + EnvironmentExtensions.ClearCache(); } public void Dispose() @@ -44,16 +46,17 @@ public void DefaultLanguageWorkersDir() var expectedWorkersDir = Path.Combine(Path.GetDirectoryName(new Uri(typeof(RpcWorkerConfigFactory).Assembly.Location).LocalPath), RpcWorkerConstants.DefaultWorkersDirectoryName); var config = new ConfigurationBuilder().Build(); var testLogger = new TestLogger("test"); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var testScriptHostManager = new Mock(); var testLoggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(testLoggerFactory, FileUtility.Instance, optionsMonitor); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(testLoggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); - string workersDirPath = workerConfigurationResolver.GetConfigurationInfo().WorkersRootDirPath; + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); - Assert.Equal(expectedWorkersDir, workersDirPath); + Assert.Equal(expectedWorkersDir, optionsMonitor.CurrentValue.WorkersRootDirPath); } [Theory] @@ -83,7 +86,8 @@ public void GetDefaultWorkersDirectory_Returns_Expected(bool expectedValue) .Returns((string dir, string workersDirName) => Path.Combine(dir, workersDirName)); var mockScriptHostManager = new Mock(); - var optionsSetup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, config, mockScriptHostManager.Object, fileSystemMock.Object); + var env = new Mock(); + var optionsSetup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, config, env.Object, fileSystemMock.Object, mockScriptHostManager.Object, new OptionsWrapper(new FunctionsHostingConfigOptions())); if (expectedValue) { @@ -108,10 +112,12 @@ public void LanguageWorker_WorkersDir_Set() var testLogger = new TestLogger("test"); var testScriptHostManager = new Mock(); var mockLogger = new Mock(); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(mockLogger.Object, FileUtility.Instance, optionsMonitor); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(mockLogger.Object, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); Assert.Equal(expectedWorkersDir, optionsMonitor.CurrentValue.WorkersRootDirPath); } @@ -130,14 +136,15 @@ public void LanguageWorker_WorkersDir_NotSet() var testLogger = new TestLogger("test"); var testLoggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testScriptHostManager = new Mock(); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(testLoggerFactory, FileUtility.Instance, optionsMonitor); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(testLoggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); - var workersDirPath = workerConfigurationResolver.GetConfigurationInfo().WorkersRootDirPath; + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); - Assert.Equal(expectedWorkersDir, workersDirPath); + Assert.Equal(expectedWorkersDir, optionsMonitor.CurrentValue.WorkersRootDirPath); } [Fact] @@ -161,13 +168,15 @@ public void WorkerDescription_Skipped_When_Profile_Disables_Worker() var testLogger = new TestLogger("test"); var testLoggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); _testEnvironment.SetEnvironmentVariable("ENV_VAR_BAR", "True"); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var loggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testScriptHostManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); var workerConfigs = configFactory.GetConfigs(); var errors = testLogger.GetLogMessages().Where(m => m.Exception != null).ToList(); @@ -188,14 +197,16 @@ public void JavaPath_FromEnvVars() var configBuilder = ScriptSettingsManager.CreateDefaultConfigurationBuilder(); var config = configBuilder.Build(); var scriptSettingsManager = new ScriptSettingsManager(config); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var loggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testLogger = loggerFactory.CreateLogger("test"); var testScriptHostManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); var workerConfigs = configFactory.GetConfigs(); var javaPath = workerConfigs.FirstOrDefault(c => c.Description.Language.Equals("java", StringComparison.OrdinalIgnoreCase)).Description.DefaultExecutablePath; @@ -203,18 +214,23 @@ public void JavaPath_FromEnvVars() Assert.Contains(@"/bin/java", javaPath); } - [Fact] - public void DefaultWorkerConfigs_Overrides_DefaultWorkerRuntimeVersion_AppSetting() + [Theory] + [InlineData("3.8", "3.8")] + [InlineData(null, "3.12")] + public void DefaultWorkerConfigs_Overrides_DefaultWorkerRuntimeVersion_AppSetting(string setting, string output) { var testEnvVariables = new Dictionary { - { "languageWorkers:python:defaultRuntimeVersion", "3.8" } + { "languageWorkers:python:defaultRuntimeVersion", setting } }; var configBuilder = ScriptSettingsManager.CreateDefaultConfigurationBuilder() .AddInMemoryCollection(testEnvVariables); var config = configBuilder.Build(); var scriptSettingsManager = new ScriptSettingsManager(config); _testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "1"); + _testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteSku, "Windows"); + var workerProfileLogger = new TestLogger(); + var workerProfileManager = new WorkerProfileManager(workerProfileLogger, _testEnvironment); using var variables = new TestScopedSettings(scriptSettingsManager, testEnvVariables); @@ -222,12 +238,13 @@ public void DefaultWorkerConfigs_Overrides_DefaultWorkerRuntimeVersion_AppSettin var loggerFactory = new LoggerFactory(); loggerFactory.AddProvider(loggerProvider); var testLogger = loggerFactory.CreateLogger("test"); + var testMetricLogger = new TestMetricsLogger(); var testScriptHostManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, workerProfileManager, SystemRuntimeInformation.Instance, optionsMonitor); + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); var workerConfigs = configFactory.GetConfigs(); var pythonWorkerConfig = workerConfigs.FirstOrDefault(w => w.Description.Language.Equals("python", StringComparison.OrdinalIgnoreCase)); @@ -235,33 +252,38 @@ public void DefaultWorkerConfigs_Overrides_DefaultWorkerRuntimeVersion_AppSettin Assert.Equal(5, workerConfigs.Count); Assert.NotNull(pythonWorkerConfig); Assert.NotNull(powershellWorkerConfig); - Assert.Equal("3.8", pythonWorkerConfig.Description.DefaultRuntimeVersion); + Assert.Equal(output, pythonWorkerConfig.Description.DefaultRuntimeVersion); Assert.Equal("7.4", powershellWorkerConfig.Description.DefaultRuntimeVersion); } - [Fact] - public void DefaultWorkerConfigs_Overrides_VersionAppSetting() + [Theory] + [InlineData("7.4", "7.4")] + [InlineData("7.2", "7.2")] + [InlineData(null, "7.4")] + public void DefaultWorkerConfigs_Overrides_VersionAppSetting(string setting, string output) { var testEnvironment = new TestEnvironment(); - testEnvironment.SetEnvironmentVariable("FUNCTIONS_WORKER_RUNTIME_VERSION", "7.4"); + testEnvironment.SetEnvironmentVariable("FUNCTIONS_WORKER_RUNTIME_VERSION", setting); testEnvironment.SetEnvironmentVariable("FUNCTIONS_WORKER_RUNTIME", "powerShell"); var configBuilder = ScriptSettingsManager.CreateDefaultConfigurationBuilder(); var config = configBuilder.Build(); var scriptSettingsManager = new ScriptSettingsManager(config); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var loggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testLogger = loggerFactory.CreateLogger("test"); var testScriptHostManager = new Mock(); - var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, testEnvironment, testScriptHostManager.Object, null); - var resolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, resolver); + var resolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, resolver, optionsMonitor); var workerConfigs = configFactory.GetConfigs(); var powershellWorkerConfig = workerConfigs.FirstOrDefault(w => w.Description.Language.Equals("powershell", StringComparison.OrdinalIgnoreCase)); Assert.Equal(1, workerConfigs.Count); Assert.NotNull(powershellWorkerConfig); - Assert.Equal("7.4", powershellWorkerConfig.Description.DefaultRuntimeVersion); + Assert.Equal(output, powershellWorkerConfig.Description.DefaultRuntimeVersion); } [Theory] @@ -284,15 +306,17 @@ public void ShouldAddProvider_Returns_Expected(string workerLanguage, string wor var config = new ConfigurationBuilder().Build(); var loggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testLogger = loggerFactory.CreateLogger("test"); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); var testScriptHostManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); - var rpcWorkerConfigFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + var rpcWorkerConfigFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); _testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); - Assert.Equal(expectedResult, rpcWorkerConfigFactory.ShouldAddWorkerConfig(workerLanguage)); + Assert.Equal(expectedResult, WorkerConfigurationHelper.ShouldAddWorkerConfig(workerLanguage, placeholderMode, false, testLogger, workerRuntime)); } [Theory] @@ -337,10 +361,12 @@ public void GetWorkerProcessCount_Tests(bool defaultWorkerConfig, bool setProces var testScriptHostManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); var mockLogger = new Mock(); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(mockLogger.Object, FileUtility.Instance, optionsMonitor); - var rpcWorkerConfigFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); - var result = rpcWorkerConfigFactory.GetWorkerProcessCount(workerConfig); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(mockLogger.Object, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + var rpcWorkerConfigFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); + var result = WorkerConfigurationHelper.GetWorkerProcessCount(workerConfig, _testEnvironment.GetEnvironmentVariable(RpcWorkerConstants.FunctionsWorkerProcessCountSettingName), _testEnvironment.GetEffectiveCoresCount()); if (defaultWorkerConfig) { @@ -383,19 +409,21 @@ public void GetWorkerProcessCount_ThrowsException_Tests() var testScriptHostManager = new Mock(); var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); var mockLogger = new Mock(); + var testMetricLogger = new TestMetricsLogger(); + var testProfileManager = new Mock(); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(mockLogger.Object, FileUtility.Instance, optionsMonitor); - var rpcWorkerConfigFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(mockLogger.Object, testMetricLogger, FileUtility.Instance, testProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); + var rpcWorkerConfigFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), _testWorkerProfileManager, workerConfigurationResolver, optionsMonitor); - var resultEx1 = Assert.Throws(() => rpcWorkerConfigFactory.GetWorkerProcessCount(workerConfig)); + var resultEx1 = Assert.Throws(() => WorkerConfigurationHelper.GetWorkerProcessCount(workerConfig, _testEnvironment.GetEnvironmentVariable(RpcWorkerConstants.FunctionsWorkerProcessCountSettingName), _testEnvironment.GetEffectiveCoresCount())); Assert.Contains("ProcessCount must be greater than 0", resultEx1.Message); workerConfig = CreateWorkerConfig(40, 10, "00:10:00", false); - var resultEx2 = Assert.Throws(() => rpcWorkerConfigFactory.GetWorkerProcessCount(workerConfig)); + var resultEx2 = Assert.Throws(() => WorkerConfigurationHelper.GetWorkerProcessCount(workerConfig, _testEnvironment.GetEnvironmentVariable(RpcWorkerConstants.FunctionsWorkerProcessCountSettingName), _testEnvironment.GetEffectiveCoresCount())); Assert.Contains("ProcessCount must not be greater than MaxProcessCount", resultEx2.Message); workerConfig = CreateWorkerConfig(10, 10, "-800", false); - var resultEx3 = Assert.Throws(() => rpcWorkerConfigFactory.GetWorkerProcessCount(workerConfig)); + var resultEx3 = Assert.Throws(() => WorkerConfigurationHelper.GetWorkerProcessCount(workerConfig, _testEnvironment.GetEnvironmentVariable(RpcWorkerConstants.FunctionsWorkerProcessCountSettingName), _testEnvironment.GetEffectiveCoresCount())); Assert.Contains("value could not be converted to System.TimeSpan", resultEx3.Message); } diff --git a/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigTests.cs b/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigTests.cs index 5911e3271c..21d3ade9de 100644 --- a/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigTests.cs +++ b/test/WebJobs.Script.Tests/Workers/Rpc/RpcWorkerConfigTests.cs @@ -144,6 +144,24 @@ public void ReadWorkerProviderFromConfig_Concatenate_ArgsFromSettings_ArgsFromWo Assert.True(workerConfig.Description.Arguments.Contains("--expose-http2")); } + [Fact] + public void ReadWorkerProviderFromConfig_AddProvidersFromAppSettings() + { + var configs = new List() { MakeTestConfig(testLanguage, []) }; + TestMetricsLogger testMetricsLogger = new TestMetricsLogger(); + // Creates temp directory w/ worker.config.json and runs ReadWorkerProviderFromConfig + string path = Path.GetFullPath("..\\..\\..\\..\\test\\TestWorkers\\ProbingPaths\\functionsworkers\\node\\3.10.1"); + Dictionary keyValuePairs = new Dictionary + { + [$"{RpcWorkerConstants.LanguageWorkersSectionName}:node:{WorkerConstants.WorkerDirectorySectionName}"] = path + }; + var workerConfigs = TestReadWorkerProviderFromConfig(configs, new TestLogger("node"), testMetricsLogger, "node", keyValuePairs); + AreRequiredMetricsEmitted(testMetricsLogger); + Assert.Equal(workerConfigs.Count(), 2); + RpcWorkerConfig workerConfig = workerConfigs.Where(p => p.Description.Language == "node").First(); + Assert.Equal(Path.Combine(path, "worker.config.json"), workerConfig.Description.DefaultWorkerPath); + } + [Fact] public void ReadWorkerProviderFromConfig_InvalidConfigFile() { @@ -393,7 +411,7 @@ public void LanguageWorker_FormatWorkerPath_EnvironmentVersionSet( var config = configBuilder.Build(); var scriptSettingsManager = new ScriptSettingsManager(config); var testLogger = new TestLogger("test"); - workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, _testEnvironment, testLogger); + workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, "python", environmentRuntimeVersion, testLogger); // Override file exists to return true workerDescription.FileExists = path => @@ -449,7 +467,7 @@ public void LanguageWorker_FormatWorkerPath_EnvironmentVersionNotSet( var config = configBuilder.Build(); var scriptSettingsManager = new ScriptSettingsManager(config); var testLogger = new TestLogger("test"); - workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, _testEnvironment, testLogger); + workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, "python", null, testLogger); Assert.Equal(expectedPath, workerDescription.DefaultWorkerPath); Assert.Equal("3.6", workerDescription.DefaultRuntimeVersion); @@ -490,7 +508,7 @@ public void LanguageWorker_FormatWorkerPath_UnsupportedArchitecture(Architecture mockRuntimeInfo.Setup(r => r.GetOSArchitecture()).Returns(unsupportedArch); mockRuntimeInfo.Setup(r => r.GetOSPlatform()).Returns(OSPlatform.Linux); - var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(mockRuntimeInfo.Object, _testEnvironment, testLogger)); + var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(mockRuntimeInfo.Object, "python", null, testLogger)); Assert.Equal(ex.Message, $"Architecture {unsupportedArch.ToString()} is not supported for language {workerDescription.Language}"); } @@ -526,7 +544,7 @@ public void LanguageWorker_FormatWorkerPath_UnsupportedOS() mockRuntimeInfo.Setup(r => r.GetOSArchitecture()).Returns(Architecture.X64); mockRuntimeInfo.Setup(r => r.GetOSPlatform()).Returns(bogusOS); - var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(mockRuntimeInfo.Object, _testEnvironment, testLogger)); + var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(mockRuntimeInfo.Object, "python", null, testLogger)); Assert.Equal(ex.Message, $"OS BogusOS is not supported for language {workerDescription.Language}"); } @@ -553,7 +571,7 @@ public void LanguageWorker_FormatWorkerPath_UnsupportedDefaultRuntimeVersion() var scriptSettingsManager = new ScriptSettingsManager(config); var testLogger = new TestLogger("test"); - var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, _testEnvironment, testLogger)); + var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, "python", null, testLogger)); Assert.Equal(ex.Message, $"Version {workerDescription.DefaultRuntimeVersion} is not supported for language {workerDescription.Language}"); } @@ -590,7 +608,7 @@ public void LanguageWorker_FormatWorkerPath_UnsupportedEnvironmentRuntimeVersion var scriptSettingsManager = new ScriptSettingsManager(config); var testLogger = new TestLogger("test"); - var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, _testEnvironment, testLogger)); + var ex = Assert.Throws(() => workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, "python", _testEnvironment.GetEnvironmentVariable(RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName), testLogger)); Assert.Equal(ex.Message, expectedExceptionMessage); } @@ -612,7 +630,7 @@ public void LanguageWorker_FormatWorkerPath_DefualtRuntimeVersion_WorkerRuntimeM DefaultRuntimeVersion = "3.7" // Ignore this if environment is set }; var testLogger = new TestLogger("test"); - workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, _testEnvironment, testLogger); + workerDescription.FormatWorkerPathIfNeeded(_testSysRuntimeInfo, "python", null, testLogger); Assert.Equal("3.7", workerDescription.DefaultRuntimeVersion); } @@ -684,12 +702,19 @@ private IEnumerable TestReadWorkerProviderFromConfig(IEnumerabl var scriptHostOptions = new ScriptJobHostOptions(); var scriptSettingsManager = new ScriptSettingsManager(config); var workerProfileManager = new Mock(); + var testMetricLogger = new TestMetricsLogger(); var testScriptHostManager = new Mock(); var loggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); + + var loggerFactoryMock = new Mock(); + loggerFactoryMock + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(testLogger); + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, _testEnvironment, testScriptHostManager.Object, null); - var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactory, FileUtility.Instance, optionsMonitor); + var workerConfigurationResolver = new DefaultWorkerConfigurationResolver(loggerFactoryMock.Object, testMetricsLogger, FileUtility.Instance, workerProfileManager.Object, SystemRuntimeInformation.Instance, optionsMonitor); - var configFactory = new RpcWorkerConfigFactory(config, testLogger, _testSysRuntimeInfo, _testEnvironment, new TestMetricsLogger(), workerProfileManager.Object, workerConfigurationResolver); + var configFactory = new RpcWorkerConfigFactory(testLogger, _testSysRuntimeInfo, new TestMetricsLogger(), workerProfileManager.Object, workerConfigurationResolver, optionsMonitor); if (appSvcEnv) { @@ -699,12 +724,10 @@ private IEnumerable TestReadWorkerProviderFromConfig(IEnumerabl }; using (var variables = new TestScopedSettings(scriptSettingsManager, testEnvVariables)) { - configFactory.BuildWorkerProviderDictionary(); return configFactory.GetConfigs(); } } - configFactory.BuildWorkerProviderDictionary(); return configFactory.GetConfigs(); } finally diff --git a/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverOptionsSetupTests.cs b/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverOptionsSetupTests.cs index feb150f709..d0b8539f65 100644 --- a/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverOptionsSetupTests.cs +++ b/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverOptionsSetupTests.cs @@ -1,15 +1,18 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Collections.Immutable; using System.Linq; using System.Text.Json; +using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Azure.WebJobs.Script.Workers; using Microsoft.Azure.WebJobs.Script.Workers.Rpc; using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.WebJobs.Script.Tests; using Moq; using Xunit; @@ -24,13 +27,14 @@ public void Configure_WithEnvironmentValues_SetsCorrectValues() var loggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testEnvironment = new TestEnvironment(); var mockScriptHostManager = new Mock(); + var hostingOptions = new FunctionsHostingConfigOptions(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { [$"{RpcWorkerConstants.LanguageWorkersSectionName}:{WorkerConstants.WorkersDirectorySectionName}"] = "/default/workers", }).Build(); - var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, mockScriptHostManager.Object, FileUtility.Instance); + var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); var options = new WorkerConfigurationResolverOptions(); setup.Configure(options); @@ -38,7 +42,7 @@ public void Configure_WithEnvironmentValues_SetsCorrectValues() } [Fact] - public void Configure_WithEnvironmentValues_UpdatedConfiguration_SetsCorrectValues() + public void Configure_UpdatedConfiguration_SetsCorrectValues() { var loggerProvider = new TestLoggerProvider(); var loggerFactory = new LoggerFactory(); @@ -48,7 +52,7 @@ public void Configure_WithEnvironmentValues_UpdatedConfiguration_SetsCorrectValu var mockScriptHostManager = new Mock(); var mockServiceProvider = new Mock(); var configuration = new ConfigurationBuilder().Build(); - + var hostingOptions = new FunctionsHostingConfigOptions(); var latestConfiguration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -60,18 +64,18 @@ public void Configure_WithEnvironmentValues_UpdatedConfiguration_SetsCorrectValu .Setup(sp => sp.GetService(typeof(IConfiguration))) .Returns(latestConfiguration); - var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, mockScriptHostManager.Object, FileUtility.Instance); + var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); var options = new WorkerConfigurationResolverOptions(); setup.Configure(options); var logs = loggerProvider.GetAllLogMessages(); Assert.Equal("/default/workers", options.WorkersRootDirPath); - Assert.Single(logs.Where(l => l.FormattedMessage == "Found configuration section 'languageWorkers:workersDirectory' in 'latestConfiguration'.")); + Assert.Single(logs.Where(l => l.FormattedMessage == "Found configuration section 'languageWorkers:workersDirectory' in JobHost.")); } [Fact] - public void Configure_WithEnvironmentValues_WithConfiguration_SetsCorrectValues() + public void Configure_WithConfiguration_SetsCorrectValues() { var loggerProvider = new TestLoggerProvider(); var loggerFactory = new LoggerFactory(); @@ -80,6 +84,7 @@ public void Configure_WithEnvironmentValues_WithConfiguration_SetsCorrectValues( var testEnvironment = new TestEnvironment(); var mockScriptHostManager = new Mock(); var mockServiceProvider = new Mock(); + var hostingOptions = new FunctionsHostingConfigOptions(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -94,14 +99,14 @@ public void Configure_WithEnvironmentValues_WithConfiguration_SetsCorrectValues( .Setup(sp => sp.GetService(typeof(IConfiguration))) .Returns(latestConfiguration); - var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, mockScriptHostManager.Object, FileUtility.Instance); + var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); var options = new WorkerConfigurationResolverOptions(); setup.Configure(options); var logs = loggerProvider.GetAllLogMessages(); Assert.Equal("/default/workers", options.WorkersRootDirPath); - Assert.Single(logs.Where(l => l.FormattedMessage == "Found configuration section 'languageWorkers:workersDirectory' in '_configuration'.")); + Assert.Single(logs.Where(l => l.FormattedMessage == "Found configuration section 'languageWorkers:workersDirectory' in WebHost.")); } [Fact] @@ -110,13 +115,14 @@ public void Configure_WithNullConfigValues_SetsCorrectValues() var testLoggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); var testEnvironment = new TestEnvironment(); var mockScriptHostManager = new Mock(); + var hostingOptions = new FunctionsHostingConfigOptions(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { [$"{RpcWorkerConstants.LanguageWorkersSectionName}:{WorkerConstants.WorkersDirectorySectionName}"] = null, }).Build(); - var setup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, mockScriptHostManager.Object, FileUtility.Instance); + var setup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); var options = new WorkerConfigurationResolverOptions(); setup.Configure(options); @@ -131,8 +137,9 @@ public void Configure_WorkerConfigurationResolverOptions() var testEnvironment = new TestEnvironment(); var mockScriptHostManager = new Mock(); var configuration = new ConfigurationBuilder().Build(); + var hostingOptions = new FunctionsHostingConfigOptions(); - var setup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, mockScriptHostManager.Object, FileUtility.Instance); + var setup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); var options = new WorkerConfigurationResolverOptions(); setup.Configure(options); @@ -145,7 +152,20 @@ public void Format_SerializesOptionsToJson() { var options = new WorkerConfigurationResolverOptions { - WorkersRootDirPath = "/test/workers" + WorkersRootDirPath = "/test/workers", + ReleaseChannel = "standard", + WorkerRuntime = "node", + IsPlaceholderModeEnabled = false, + IsMultiLanguageWorkerEnvironment = true, + ProbingPaths = new List { "path1", "path2" }, + WorkersAvailableForResolution = new HashSet { "node", "python" }.ToImmutableHashSet(), + WorkerDescriptionOverrides = ImmutableDictionary.Empty.Add("node", new RpcWorkerDescription { Language = "node" }), + IgnoredWorkerVersions = new Dictionary> + { + { "node", new HashSet { new Version("14.0.0"), new Version("16.0.0") } }, + { "python", new HashSet { new Version("3.6"), new Version("3.7") } } + }.ToImmutableDictionary(), + IsDynamicWorkerResolutionEnabled = true }; string json = options.Format(); @@ -159,6 +179,24 @@ public void Format_SerializesOptionsToJson() var root = jsonDocument.RootElement; Assert.True(root.TryGetProperty("WorkersRootDirPath", out var workersDirPathProperty)); Assert.Equal("/test/workers", workersDirPathProperty.GetString()); + Assert.True(root.TryGetProperty("ReleaseChannel", out var releaseChannelProperty)); + Assert.Equal("standard", releaseChannelProperty.GetString()); + Assert.True(root.TryGetProperty("WorkerRuntime", out var workerRuntimeProperty)); + Assert.Equal("node", workerRuntimeProperty.GetString()); + Assert.True(root.TryGetProperty("IsPlaceholderModeEnabled", out var placeholderModeProperty)); + Assert.False(placeholderModeProperty.GetBoolean()); + Assert.True(root.TryGetProperty("IsMultiLanguageWorkerEnvironment", out var multiLangEnvProperty)); + Assert.True(multiLangEnvProperty.GetBoolean()); + Assert.True(root.TryGetProperty("ProbingPaths", out var probingPathsProperty)); + Assert.Equal(2, probingPathsProperty.GetArrayLength()); + Assert.True(root.TryGetProperty("WorkersAvailableForResolution", out var workersAvailableProperty)); + Assert.Equal(2, workersAvailableProperty.GetArrayLength()); + Assert.True(root.TryGetProperty("WorkerDescriptionOverrides", out var workerDescriptionOverridesProperty)); + Assert.NotNull(workerDescriptionOverridesProperty); + Assert.True(root.TryGetProperty("IgnoredWorkerVersions", out var ignoredWorkerVersionsProperty)); + Assert.NotNull(ignoredWorkerVersionsProperty); + Assert.True(root.TryGetProperty("IsDynamicWorkerResolutionEnabled", out var dynamicWorkerResolutionProperty)); + Assert.True(dynamicWorkerResolutionProperty.GetBoolean()); } [Fact] @@ -181,5 +219,221 @@ public void Format_WithNullProperties_SerializesSuccessfully() Assert.True(root.TryGetProperty("WorkersRootDirPath", out var workersDirPathProperty)); Assert.Equal(null, workersDirPathProperty.GetString()); } + + [Fact] + public void Configure_WithRealEnvironmentValues_SetsCorrectDefaults() + { + // Arrange + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + EnvironmentExtensions.ClearCache(); + var testEnvironment = new TestEnvironment(); + var configBuilder = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{RpcWorkerConstants.LanguageWorkersSectionName}:{WorkerConstants.WorkersDirectorySectionName}"] = "/default/workers", + [$"{RpcWorkerConstants.LanguageWorkersSectionName}:{RpcWorkerConstants.WorkerProbingPathsSectionName}:0"] = "testPath1", + [$"{RpcWorkerConstants.LanguageWorkersSectionName}:{RpcWorkerConstants.WorkerProbingPathsSectionName}:1"] = "testPath2", + }); + var configuration = configBuilder.Build(); + var mockScriptHostManager = new Mock(); + + testEnvironment.SetEnvironmentVariable("FUNCTIONS_WORKER_RUNTIME", "java"); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java"); + + var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var options = new WorkerConfigurationResolverOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.Equal(ScriptConstants.LatestPlatformChannelNameUpper, options.ReleaseChannel); + Assert.False(options.IsPlaceholderModeEnabled); + Assert.False(options.IsMultiLanguageWorkerEnvironment); + Assert.Equal("/default/workers", options.WorkersRootDirPath); + Assert.NotNull(options.WorkerDescriptionOverrides); + + Assert.Equal(2, options.ProbingPaths.Count); + Assert.True(options.ProbingPaths.Contains("testPath1")); + Assert.True(options.ProbingPaths.Contains("testPath2")); + + Assert.True(options.WorkersAvailableForResolution.Any()); + + var logs = loggerProvider.GetAllLogMessages(); + Assert.Single(logs.Where(l => l.FormattedMessage == "Worker probing paths specified via configuration: testPath1, testPath2.")); + } + + [Fact] + public void Configure_NoProbingPaths_SetsCorrectValues() + { + // Arrange + var testLoggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); + var testEnvironment = new TestEnvironment(); + var configuration = new ConfigurationBuilder().Build(); + var mockScriptHostManager = new Mock(); + testEnvironment.SetEnvironmentVariable("FUNCTIONS_WORKER_RUNTIME", "java"); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java"); + + var setup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var options = new WorkerConfigurationResolverOptions(); + setup.Configure(options); + + Assert.Equal(0, options.ProbingPaths.Count); + } + + [Fact] + public void Configure_WithEnvironmentValues_SetsValues() + { + // Arrange + EnvironmentExtensions.ClearCache(); + var testEnvironment = new TestEnvironment(); + var testLoggerFactory = WorkerConfigurationResolverTestsHelper.GetTestLoggerFactory(); + var configBuilder = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{RpcWorkerConstants.LanguageWorkersSectionName}:{WorkerConstants.WorkersDirectorySectionName}"] = "/default/workers", + }); + var configuration = configBuilder.Build(); + var mockScriptHostManager = new Mock(); + + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "java"); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AntaresPlatformReleaseChannel, "standard"); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AppKind, "workflowapp"); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java|node"); + + var setup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var options = new WorkerConfigurationResolverOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.Equal("java", options.WorkerRuntime); + Assert.Equal("standard", options.ReleaseChannel); + Assert.False(options.IsPlaceholderModeEnabled); + Assert.True(options.IsMultiLanguageWorkerEnvironment); + Assert.Equal("/default/workers", options.WorkersRootDirPath); + Assert.NotNull(options.WorkerDescriptionOverrides); + + Assert.NotNull(options.ProbingPaths); + Assert.False(options.ProbingPaths.Any()); + + Assert.True(options.WorkersAvailableForResolution.Count == 2); + Assert.True(options.WorkersAvailableForResolution.Contains("java")); + Assert.True(options.WorkersAvailableForResolution.Contains("node")); + } + + [Theory] + [InlineData(null, "node", true)] + [InlineData(null, "java|node", true)] + [InlineData(null, "", false)] + [InlineData(null, "| ", false)] + [InlineData(null, null, false)] + [InlineData(ScriptConstants.FeatureFlagDisableDynamicWorkerResolution, "node", false)] + [InlineData(ScriptConstants.FeatureFlagDisableDynamicWorkerResolution, "java|node", false)] + [InlineData(ScriptConstants.FeatureFlagDisableDynamicWorkerResolution, "| ", false)] + + public void IsDynamicWorkerResolutionEnabled_HostingConfigAndFeatureFlags_WorksAsExpected(string featureFlagValue, string hostingConfigSetting, bool expected) + { + var config = new ConfigurationBuilder().Build(); + var mockScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, hostingConfigSetting); + + var testEnvironment = new TestEnvironment(); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebJobsFeatureFlags, featureFlagValue); + + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, testEnvironment, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + bool result = optionsMonitor.CurrentValue.IsDynamicWorkerResolutionEnabled; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("node", "node", null, "0", true)] + [InlineData("node", "java", null, "0", false)] + [InlineData("java|node", null, null, "0", true)] + [InlineData("node", "node", "workflowapp", "0", true)] + [InlineData("java|node", null, "workflowapp", "0", true)] + [InlineData("| ", null, "workflowapp", "0", false)] + [InlineData("java|node", null, null, "1", true)] + [InlineData("node", "java", null, "1", true)] + public void IsDynamicWorkerResolutionEnabled_WorksAsExpected(string hostingConfigSetting, string workerRuntime, string multilanguageApp, string placeholdermode, bool expected) + { + EnvironmentExtensions.ClearCache(); + var config = new ConfigurationBuilder().Build(); + var mockScriptHostManager = new Mock(); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, hostingConfigSetting); + + var testEnvironment = new TestEnvironment(); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AppKind, multilanguageApp); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, workerRuntime); + testEnvironment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, placeholdermode); + + var optionsMonitor = WorkerConfigurationResolverTestsHelper.GetTestWorkerConfigurationResolverOptions(config, testEnvironment, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + bool result = optionsMonitor.CurrentValue.IsDynamicWorkerResolutionEnabled; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("| ", 0)] + [InlineData("", 0)] + [InlineData(null, 0)] + [InlineData("java:1.0.0|python:2.0.0|java:1.1.1||node:|dotnet-isolated:abc", 2)] + public void IgnoredWorkerVersions_WorksAsExpected(string hostingConfigSetting, int expected) + { + var loggerProvider = new TestLoggerProvider(); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddProvider(loggerProvider); + + var config = new ConfigurationBuilder().Build(); + var mockScriptHostManager = new Mock(); + var testEnvironment = new TestEnvironment(); + testEnvironment.SetEnvironmentVariable("APP_KIND", "workflowapp"); + + var hostingOptions = new FunctionsHostingConfigOptions(); + hostingOptions.Features.Add(RpcWorkerConstants.IgnoredWorkerVersions, hostingConfigSetting); + hostingOptions.Features.Add(RpcWorkerConstants.WorkersAvailableForDynamicResolution, "java"); + + var setup = new WorkerConfigurationResolverOptionsSetup(loggerFactory, config, testEnvironment, FileUtility.Instance, mockScriptHostManager.Object, new OptionsWrapper(hostingOptions)); + var options = new WorkerConfigurationResolverOptions(); + setup.Configure(options); + + var logs = loggerProvider.GetAllLogMessages(); + + var ignoreWorkerVersions = options.IgnoredWorkerVersions; + Assert.NotNull(ignoreWorkerVersions); + Assert.Equal(ignoreWorkerVersions.Count, expected); + + if (expected != 0) + { + ignoreWorkerVersions.TryGetValue("java", out HashSet javaValue); + Assert.NotNull(javaValue); + Assert.Equal(javaValue.Count, 2); + Assert.Contains(new Version("1.0.0"), javaValue); + Assert.Contains(new Version("1.1.1"), javaValue); + + ignoreWorkerVersions.TryGetValue("python", out HashSet pyValue); + Assert.NotNull(pyValue); + Assert.Equal(pyValue.Count, 1); + Assert.Contains(new Version("2.0.0"), pyValue); + + Assert.Single(logs.Where(l => l.FormattedMessage == "Skipping 'node:' due to invalid format for ignored worker version. Expected format is 'WorkerName:Version'.")); + Assert.Single(logs.Where(l => l.FormattedMessage == "Skipping 'dotnet-isolated:abc' due to invalid version format: 'abc' for worker 'dotnet-isolated'.")); + } + } } } diff --git a/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverTestsHelper.cs b/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverTestsHelper.cs index 90022356ac..6ebd565d27 100644 --- a/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverTestsHelper.cs +++ b/test/WebJobs.Script.Tests/Workers/Rpc/WorkerConfigurationResolverTestsHelper.cs @@ -1,6 +1,10 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.IO; +using System.Text; +using System.Text.Json; using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Azure.WebJobs.Script.Workers.Rpc.Configuration; using Microsoft.Extensions.Configuration; @@ -24,7 +28,7 @@ internal static IOptionsMonitor GetTestWorke } var testLoggerFactory = GetTestLoggerFactory(); - var resolverOptionsSetup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, scriptHostManager, FileUtility.Instance); + var resolverOptionsSetup = new WorkerConfigurationResolverOptionsSetup(testLoggerFactory, configuration, environment, FileUtility.Instance, scriptHostManager, functionsHostingConfigOptions); var resolverOptions = new WorkerConfigurationResolverOptions(); resolverOptionsSetup.Configure(resolverOptions); @@ -44,5 +48,25 @@ internal static LoggerFactory GetTestLoggerFactory() return loggerFactory; } + + internal static IConfiguration GetConfigurationWithProbingPaths(List probingPaths) + { + var jsonObj = new + { + languageWorkers = new + { + probingPaths + } + }; + + var jsonString = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); + var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(jsonString)); + + var configurationBuilder = new ConfigurationBuilder() + .Add(new ScriptEnvironmentVariablesConfigurationSource()) + .AddJsonStream(jsonStream); + + return configurationBuilder.Build(); + } } }