src/WebJobs.Script.WebHost/Management/AtlasInstanceManager.cs (349 lines of code) (raw):

// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; using Microsoft.Azure.Storage; using Microsoft.Azure.Storage.File; using Microsoft.Azure.WebJobs.Script.Diagnostics; using Microsoft.Azure.WebJobs.Script.WebHost.Configuration; using Microsoft.Azure.WebJobs.Script.WebHost.Management.LinuxSpecialization; using Microsoft.Azure.WebJobs.Script.WebHost.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; namespace Microsoft.Azure.WebJobs.Script.WebHost.Management { public class AtlasInstanceManager : LinuxInstanceManager { private readonly object _assignmentLock = new object(); private readonly ILogger _logger; private readonly IMetricsLogger _metricsLogger; private readonly IMeshServiceClient _meshServiceClient; private readonly IRunFromPackageHandler _runFromPackageHandler; private readonly IPackageDownloadHandler _packageDownloadHandler; private readonly IEnvironment _environment; private readonly IOptionsFactory<ScriptApplicationHostOptions> _optionsFactory; private readonly HttpClient _client; private readonly IScriptWebHostEnvironment _webHostEnvironment; public AtlasInstanceManager(IOptionsFactory<ScriptApplicationHostOptions> optionsFactory, IHttpClientFactory httpClientFactory, IScriptWebHostEnvironment webHostEnvironment, IEnvironment environment, ILogger<AtlasInstanceManager> logger, IMetricsLogger metricsLogger, IMeshServiceClient meshServiceClient, IRunFromPackageHandler runFromPackageHandler, IPackageDownloadHandler packageDownloadHandler) : base(httpClientFactory, webHostEnvironment, environment, logger, metricsLogger, meshServiceClient) { _client = httpClientFactory?.CreateClient() ?? throw new ArgumentNullException(nameof(httpClientFactory)); _webHostEnvironment = webHostEnvironment ?? throw new ArgumentNullException(nameof(webHostEnvironment)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _metricsLogger = metricsLogger; _meshServiceClient = meshServiceClient; _runFromPackageHandler = runFromPackageHandler ?? throw new ArgumentNullException(nameof(runFromPackageHandler)); _packageDownloadHandler = packageDownloadHandler ?? throw new ArgumentNullException(nameof(packageDownloadHandler)); _environment = environment ?? throw new ArgumentNullException(nameof(environment)); _optionsFactory = optionsFactory ?? throw new ArgumentNullException(nameof(optionsFactory)); } public override async Task<string> SpecializeMSISidecar(HostAssignmentContext context) { // No cold start optimization needed for side car scenarios if (context.IsWarmupRequest) { return null; } var msiEnabled = context.IsMSIEnabled(out var endpoint); _logger.LogInformation($"MSI enabled status: {msiEnabled}"); if (msiEnabled) { if (context.MSIContext == null && context.EncryptedTokenServiceSpecializationPayload == null) { _logger.LogWarning("Skipping specialization of MSI sidecar since MSIContext and EncryptedTokenServiceSpecializationPayload were absent"); await _meshServiceClient.NotifyHealthEvent(ContainerHealthEventType.Fatal, this.GetType(), "Could not specialize MSI sidecar since MSIContext and EncryptedTokenServiceSpecializationPayload were empty"); } else { using (_metricsLogger.LatencyEvent(MetricEventNames.LinuxContainerSpecializationMSIInit)) { var uri = new Uri(endpoint); var addressStem = GetMsiSpecializationRequestAddressStem(context); var address = $"http://{uri.Host}:{uri.Port}{addressStem}"; _logger.LogDebug($"Specializing sidecar at {address}"); StringContent payload; if (string.IsNullOrEmpty(context.EncryptedTokenServiceSpecializationPayload)) { payload = new StringContent(JsonConvert.SerializeObject(context.MSIContext), Encoding.UTF8, "application/json"); } else { payload = new StringContent(context.EncryptedTokenServiceSpecializationPayload, Encoding.UTF8); } var requestMessage = new HttpRequestMessage(HttpMethod.Post, address) { Content = payload }; var response = await _client.SendAsync(requestMessage); _logger.LogInformation($"Specialize MSI sidecar returned {response.StatusCode}"); if (!response.IsSuccessStatusCode) { var message = $"Specialize MSI sidecar call failed. StatusCode={response.StatusCode}"; _logger.LogError(message); await _meshServiceClient.NotifyHealthEvent(ContainerHealthEventType.Fatal, this.GetType(), "Failed to specialize MSI sidecar"); return message; } } } } return null; } public override async Task<string> ValidateContext(HostAssignmentContext assignmentContext) { _logger.LogInformation($"Validating host assignment context (SiteId: {assignmentContext.SiteId}, SiteName: '{assignmentContext.SiteName}'. IsWarmup: '{assignmentContext.IsWarmupRequest}')"); RunFromPackageContext pkgContext = assignmentContext.GetRunFromPkgContext(); _logger.LogInformation($"Will be using {pkgContext.EnvironmentVariableName} app setting as zip url. IsWarmup: '{assignmentContext.IsWarmupRequest}')"); if (pkgContext.IsScmRunFromPackage()) { // Not user assigned so limit validation return null; } else if (!string.IsNullOrEmpty(pkgContext.Url) && pkgContext.Url != "1") { if (Uri.TryCreate(pkgContext.Url, UriKind.Absolute, out var uri)) { if (Utility.IsResourceAzureBlobWithoutSas(uri)) { // Note: this also means we skip validation for publicly available blobs _logger.LogDebug("Skipping validation for '{pkgContext.EnvironmentVariableName}' with no SAS token", pkgContext.EnvironmentVariableName); return null; } else { // In AppService, ZipUrl == 1 means the package is hosted in azure files. // Otherwise we expect zipUrl to be a blobUri to a zip or a squashfs image var (error, contentLength) = await ValidateBlobPackageContext(pkgContext); if (string.IsNullOrEmpty(error)) { assignmentContext.PackageContentLength = contentLength; } return error; } } else { var invalidUrlError = $"Invalid url for specified for {pkgContext.EnvironmentVariableName}"; _logger.LogError(invalidUrlError); // For now we return null here instead of the actual error since this validation is new. // Eventually this could return the error message. return null; } } else if (!string.IsNullOrEmpty(assignmentContext.AzureFilesConnectionString)) { return await ValidateAzureFilesContext(assignmentContext.AzureFilesConnectionString, assignmentContext.AzureFilesContentShare); } else { _logger.LogError("Missing ZipUrl and AzureFiles config. Continue with empty root."); return null; } } protected override async Task<string> DownloadWarmupAsync(RunFromPackageContext context) { return await _packageDownloadHandler.Download(context); } private async Task<(string Error, long? ContentLength)> ValidateBlobPackageContext(RunFromPackageContext context) { string blobUri = context.Url; string eventName = context.IsWarmUpRequest ? MetricEventNames.LinuxContainerSpecializationZipHeadWarmup : MetricEventNames.LinuxContainerSpecializationZipHead; string error = null; HttpResponseMessage response = null; long? contentLength = null; try { if (!string.IsNullOrEmpty(blobUri)) { // make sure the zip uri is valid and accessible await Utility.InvokeWithRetriesAsync(async () => { try { using (_metricsLogger.LatencyEvent(eventName)) { var request = new HttpRequestMessage(HttpMethod.Head, blobUri); response = await _client.SendAsync(request); response.EnsureSuccessStatusCode(); if (response.Content != null && response.Content.Headers != null) { contentLength = response.Content.Headers.ContentLength; } } } catch (Exception e) { _logger.LogError(e, $"{eventName} failed"); throw; } }, maxRetries: 2, retryInterval: TimeSpan.FromSeconds(0.3)); // Keep this less than ~1s total } } catch (Exception e) { error = $"Invalid zip url specified (StatusCode: {response?.StatusCode})"; _logger.LogError(e, $"ValidateContext failed. IsWarmupRequest = {context.IsWarmUpRequest}"); } return (error, contentLength); } private async Task<string> ValidateAzureFilesContext(string connectionString, string contentShare) { try { var storageAccount = CloudStorageAccount.Parse(connectionString); var fileClient = storageAccount.CreateCloudFileClient(); var share = fileClient.GetShareReference(contentShare); if (!share.Exists()) { await share.CreateIfNotExistsAsync(); } return null; } catch (Exception e) { _logger.LogError(e, nameof(ValidateAzureFilesContext)); return e.Message; } } protected override async Task ApplyContextAsync(HostAssignmentContext assignmentContext) { // We need to get the non-PlaceholderMode script Path so we can unzip to the correct location. // This asks the factory to skip the PlaceholderMode check when configuring options. var options = _optionsFactory.Create(ScriptApplicationHostOptionsSetup.SkipPlaceholder); RunFromPackageContext pkgContext = assignmentContext.GetRunFromPkgContext(); if (_environment.SupportsAzureFileShareMount() || pkgContext.IsRunFromLocalPackage()) { var azureFilesMounted = false; if (assignmentContext.IsAzureFilesContentShareConfigured(_logger)) { azureFilesMounted = await _runFromPackageHandler.MountAzureFileShare(assignmentContext); } else { _logger.LogError( $"No {nameof(EnvironmentSettingNames.AzureFilesConnectionString)} or {nameof(EnvironmentSettingNames.AzureFilesContentShare)} configured. Azure FileShare will not be mounted. For PowerShell Functions, Managed Dependencies will not persisted across functions host instances."); } if (pkgContext.IsRunFromPackage(options, _logger)) { if (azureFilesMounted) { _logger.LogWarning("App is configured to use both Run-From-Package and AzureFiles. Run-From-Package will take precedence"); } var blobContextApplied = await _runFromPackageHandler.ApplyRunFromPackageContext(pkgContext, options.ScriptPath, azureFilesMounted, false); if (!blobContextApplied && azureFilesMounted) { _logger.LogWarning($"Failed to {nameof(_runFromPackageHandler.ApplyRunFromPackageContext)}. Attempting to use local disk instead"); await _runFromPackageHandler.ApplyRunFromPackageContext(pkgContext, options.ScriptPath, false); } } else if (pkgContext.IsRunFromLocalPackage()) { if (!azureFilesMounted) { const string mountErrorMessage = "App Run-From-Package is set as '1'. AzureFiles is needed but is not configured."; _logger.LogWarning(mountErrorMessage); throw new Exception(mountErrorMessage); } var blobContextApplied = await _runFromPackageHandler.ApplyRunFromPackageContext(pkgContext, options.ScriptPath, azureFilesMounted); if (!blobContextApplied) { _logger.LogWarning($"Failed to {nameof(_runFromPackageHandler.ApplyRunFromPackageContext)}."); } } else { _logger.LogInformation($"No {nameof(EnvironmentSettingNames.AzureWebsiteRunFromPackage)} configured"); } } else { if (pkgContext.IsRunFromPackage(options, _logger)) { await _runFromPackageHandler.ApplyRunFromPackageContext(pkgContext, options.ScriptPath, false); } else if (assignmentContext.IsAzureFilesContentShareConfigured(_logger)) { await _runFromPackageHandler.MountAzureFileShare(assignmentContext); } } // BYOS var storageVolumes = assignmentContext.GetBYOSEnvironmentVariables() .Select(AzureStorageInfoValue.FromEnvironmentVariable).ToList(); var mountedVolumes = (await Task.WhenAll(storageVolumes.Where(v => v != null).Select(MountStorageAccount))).Where( result => result).ToList(); if (storageVolumes.Any()) { if (mountedVolumes.Count != storageVolumes.Count) { _logger.LogWarning( $"Successfully mounted {mountedVolumes.Count} / {storageVolumes.Count} BYOS storage accounts"); } else { _logger.LogInformation( $"Successfully mounted {storageVolumes.Count} BYOS storage accounts"); } } } private async Task<bool> MountStorageAccount(AzureStorageInfoValue storageInfoValue) { try { var storageConnectionString = Utility.BuildStorageConnectionString(storageInfoValue.AccountName, storageInfoValue.AccessKey, _environment.GetStorageSuffix()); await Utility.InvokeWithRetriesAsync(async () => { try { using (_metricsLogger.LatencyEvent($"{MetricEventNames.LinuxContainerSpecializationBYOSMountPrefix}.{storageInfoValue.Type.ToString().ToLowerInvariant()}.{storageInfoValue.Id?.ToLowerInvariant()}")) { switch (storageInfoValue.Type) { case AzureStorageType.AzureFiles: if (!await _meshServiceClient.MountCifs(storageConnectionString, storageInfoValue.ShareName, storageInfoValue.MountPath)) { throw new Exception($"Failed to mount BYOS fileshare {storageInfoValue.Id}"); } break; case AzureStorageType.AzureBlob: await _meshServiceClient.MountBlob(storageConnectionString, storageInfoValue.ShareName, storageInfoValue.MountPath); break; default: throw new NotSupportedException($"Unknown BYOS storage type {storageInfoValue.Type}"); } } } catch (Exception e) { // todo: Expose any failures here as a part of a health check api. _logger.LogError(e, $"Failed to mount BYOS storage account {storageInfoValue.Id}"); throw; } _logger.LogInformation( $"Successfully mounted BYOS storage account {storageInfoValue.Id}"); }, 1, TimeSpan.FromSeconds(0.5)); return true; } catch (Exception e) { _logger.LogError(e, $"Failed to mount BYOS storage account {storageInfoValue.Id}"); return false; } } private string GetMsiSpecializationRequestAddressStem(HostAssignmentContext context) { var stem = ScriptConstants.LinuxMSISpecializationStem; if (!string.IsNullOrEmpty(context.EncryptedTokenServiceSpecializationPayload)) { _logger.LogDebug("Using encrypted TokenService payload format"); // use default encrypted API endpoint if endpoint not provided in context stem = string.IsNullOrEmpty(context.TokenServiceApiEndpoint) ? ScriptConstants.LinuxEncryptedTokenServiceSpecializationStem : context.TokenServiceApiEndpoint; } return stem; } } }