From be325d761890e720466490f70748a600bfdd5287 Mon Sep 17 00:00:00 2001 From: Ahmed ElSayed Date: Fri, 6 Oct 2017 11:34:23 -0700 Subject: [PATCH] Add function management APIs to the runtime --- .../Controllers/FunctionsController.cs | 166 +++++ .../{AdminController.cs => HostController.cs} | 18 +- .../EntityTagHeaderValueExtensions.cs | 20 + .../Extensions/FunctionMetadataExtensions.cs | 98 +++ .../FunctionMetadataResponseExtensions.cs | 21 + .../Extensions/HttpHeadersExtensions.cs | 21 + .../HttpResponseMessageExtensions.cs | 33 + .../Extensions/IEnumerableTasksExtensions.cs | 31 + .../Extensions/VfsStringExtensions.cs | 15 + .../Extensions/WebHostSettingsExtensions.cs | 11 + .../Helpers/MediaTypeMap.cs | 63 ++ .../Helpers/VfsSpecialFolders.cs | 158 +++++ .../Management/IWebFunctionsManager.cs | 21 + .../Management/VirtualFileSystem.cs | 630 ++++++++++++++++++ .../Management/WebFunctionsManager.cs | 163 +++++ .../Middleware/VirtualFileSystemMiddleware.cs | 115 ++++ .../Models/FunctionMetadataResponse.cs | 87 +++ .../Models/VfsStatEntry.cs | 36 + src/WebJobs.Script.WebHost/WebHostResolver.cs | 32 +- src/WebJobs.Script.WebHost/WebHostSettings.cs | 11 +- .../WebJobsApplicationBuilderExtension.cs | 7 +- .../WebJobsServiceCollectionExtensions.cs | 7 +- .../Config/ScriptHostConfiguration.cs | 6 + src/WebJobs.Script/Extensions/FileUtility.cs | 131 +++- .../Extensions/HttpRequestExtensions.cs | 4 + src/WebJobs.Script/Host/ScriptHost.cs | 99 +-- src/WebJobs.Script/ScriptConstants.cs | 3 +- src/WebJobs.Script/Utility.cs | 2 +- .../Controllers/Admin/AdminControllerTests.cs | 7 +- .../Managment/VirtualFileSystemFacts.cs | 233 +++++++ test/WebJobs.Script.Tests/ScriptHostTests.cs | 8 +- .../Security/SecretManagerTests.cs | 8 +- 32 files changed, 2178 insertions(+), 87 deletions(-) create mode 100755 src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs rename src/WebJobs.Script.WebHost/Controllers/{AdminController.cs => HostController.cs} (94%) create mode 100644 src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs create mode 100755 src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs create mode 100755 src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs create mode 100755 src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs create mode 100644 src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs create mode 100644 src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs create mode 100644 src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs create mode 100644 src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs create mode 100644 src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs create mode 100755 src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs create mode 100755 src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs create mode 100644 src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs create mode 100755 src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs create mode 100755 src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs create mode 100755 src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs create mode 100644 src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs create mode 100644 test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs diff --git a/src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs b/src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs new file mode 100755 index 0000000000..7d86d27023 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs @@ -0,0 +1,166 @@ +// 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.Collections.ObjectModel; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs.Script.Description; +using Microsoft.Azure.WebJobs.Script.Management.Models; +using Microsoft.Azure.WebJobs.Script.WebHost.Filters; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; +using Microsoft.Azure.WebJobs.Script.WebHost.Models; +using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers +{ + /// + /// Controller responsible for administrative and management operations on functions + /// example retriving a list of functions, invoking a function, creating a function, etc + /// + public class FunctionsController : Controller + { + private readonly IWebFunctionsManager _functionsManager; + private readonly ScriptHostManager _scriptHostManager; + private readonly ILogger _logger; + private static readonly Regex FunctionNameValidationRegex = new Regex(@"^[a-z][a-z0-9_\-]{0,127}$(? List() + { + return Ok(await _functionsManager.GetFunctionsMetadata(Request)); + } + + [HttpGet] + [Route("admin/functions/{name}")] + [Authorize(Policy = PolicyNames.AdminAuthLevel)] + public async Task Get(string name) + { + (var success, var function) = await _functionsManager.TryGetFunction(name, Request); + + return success + ? Ok(function) + : NotFound() as IActionResult; + } + + [HttpPut] + [Route("admin/functions/{name}")] + [Authorize(Policy = PolicyNames.AdminAuthLevel)] + public async Task CreateOrUpdate(string name, [FromBody] FunctionMetadataResponse functionMetadata) + { + if (!FunctionNameValidationRegex.IsMatch(name)) + { + return BadRequest($"{name} is not a valid function name"); + } + + (var success, var configChanged, var functionMetadataResponse) = await _functionsManager.CreateOrUpdate(name, functionMetadata, Request); + + if (success) + { + if (configChanged) + { + // TODO: sync triggers + } + + return Created(Request.GetDisplayUrl(), functionMetadataResponse); + } + else + { + return StatusCode(500); + } + } + + [HttpPost] + [Route("admin/functions/{name}")] + [Authorize(Policy = PolicyNames.AdminAuthLevel)] + [RequiresRunningHost] + public IActionResult Invoke(string name, [FromBody] FunctionInvocation invocation) + { + if (invocation == null) + { + return BadRequest(); + } + + FunctionDescriptor function = _scriptHostManager.Instance.GetFunctionOrNull(name); + if (function == null) + { + return NotFound(); + } + + ParameterDescriptor inputParameter = function.Parameters.First(p => p.IsTrigger); + Dictionary arguments = new Dictionary() + { + { inputParameter.Name, invocation.Input } + }; + Task.Run(() => _scriptHostManager.Instance.CallAsync(function.Name, arguments)); + + return Accepted(); + } + + [HttpGet] + [Route("admin/functions/{name}/status")] + [Authorize(Policy = PolicyNames.AdminAuthLevel)] + [RequiresRunningHost] + public IActionResult GetFunctionStatus(string name) + { + FunctionStatus status = new FunctionStatus(); + Collection functionErrors = null; + + // first see if the function has any errors + if (_scriptHostManager.Instance.FunctionErrors.TryGetValue(name, out functionErrors)) + { + status.Errors = functionErrors; + } + else + { + // if we don't have any errors registered, make sure the function exists + // before returning empty errors + FunctionDescriptor function = _scriptHostManager.Instance.Functions.FirstOrDefault(p => p.Name.ToLowerInvariant() == name.ToLowerInvariant()); + if (function == null) + { + return NotFound(); + } + } + + return Ok(status); + } + + [HttpDelete] + [Route("admin/functions/{name}")] + [Authorize(Policy = PolicyNames.AdminAuthLevel)] + public async Task Delete(string name) + { + (var found, var function) = await _functionsManager.TryGetFunction(name, Request); + if (!found) + { + return NotFound(); + } + + (var deleted, var error) = _functionsManager.TryDeleteFunction(function); + + if (deleted) + { + return NoContent(); + } + else + { + return StatusCode(StatusCodes.Status500InternalServerError, error); + } + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Controllers/AdminController.cs b/src/WebJobs.Script.WebHost/Controllers/HostController.cs similarity index 94% rename from src/WebJobs.Script.WebHost/Controllers/AdminController.cs rename to src/WebJobs.Script.WebHost/Controllers/HostController.cs index 051aebae7d..c02f20360d 100644 --- a/src/WebJobs.Script.WebHost/Controllers/AdminController.cs +++ b/src/WebJobs.Script.WebHost/Controllers/HostController.cs @@ -1,10 +1,13 @@ -// 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.Collections.ObjectModel; using System.Diagnostics; +using System.IO; +using System.IO.Compression; using System.Linq; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -14,6 +17,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.WebApiCompatShim; using Microsoft.Azure.WebJobs.Host; +using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Azure.WebJobs.Script.Description; using Microsoft.Azure.WebJobs.Script.WebHost.Authentication; using Microsoft.Azure.WebJobs.Script.WebHost.Filters; @@ -25,21 +29,21 @@ namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers { /// - /// Controller responsible for handling all administrative requests, for - /// example enqueueing function invocations, etc. + /// Controller responsible for handling all administrative requests for host operations + /// example host status, ping, log, etc /// - public class AdminController : Controller + public class HostController : Controller { private readonly WebScriptHostManager _scriptHostManager; private readonly WebHostSettings _webHostSettings; private readonly ILogger _logger; private readonly IAuthorizationService _authorizationService; - public AdminController(WebScriptHostManager scriptHostManager, WebHostSettings webHostSettings, ILoggerFactory loggerFactory, IAuthorizationService authorizationService) + public HostController(WebScriptHostManager scriptHostManager, WebHostSettings webHostSettings, ILoggerFactory loggerFactory, IAuthorizationService authorizationService) { _scriptHostManager = scriptHostManager; _webHostSettings = webHostSettings; - _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryAdminController); + _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryHostController); _authorizationService = authorizationService; } @@ -214,4 +218,4 @@ public async Task ExtensionWebHookHandler(string name, Cancellati return NotFound(); } } -} \ No newline at end of file +} diff --git a/src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs new file mode 100644 index 0000000000..7896d86823 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs @@ -0,0 +1,20 @@ +// 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.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class EntityTagHeaderValueExtensions + { + public static System.Net.Http.Headers.EntityTagHeaderValue ToSystemETag(this Microsoft.Net.Http.Headers.EntityTagHeaderValue value) + { + return value.Tag.Value.StartsWith("\"") && value.Tag.Value.EndsWith("\"") + ? new System.Net.Http.Headers.EntityTagHeaderValue(value.Tag.Value, value.IsWeak) + : new System.Net.Http.Headers.EntityTagHeaderValue($"\"{value.Tag}\"", value.IsWeak); + } + } +} diff --git a/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs new file mode 100755 index 0000000000..0cd4c7e0da --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.Config; +using Microsoft.Azure.WebJobs.Script.Description; +using Microsoft.Azure.WebJobs.Script.Management.Models; +using Microsoft.Azure.WebJobs.Script.WebHost.Helpers; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class FunctionMetadataExtensions + { + /// + /// Maps FunctionMetadata to FunctionMetadataResponse. + /// + /// FunctionMetadata to be mapped. + /// Current HttpRequest + /// ScriptHostConfig + /// Promise of a FunctionMetadataResponse + public static async Task ToFunctionMetadataResponse(this FunctionMetadata functionMetadata, HttpRequest request, ScriptHostConfiguration config) + { + var functionPath = Path.Combine(config.RootScriptPath, functionMetadata.Name); + var functionMetadataFilePath = Path.Combine(functionPath, ScriptConstants.FunctionMetadataFileName); + var baseUrl = request != null + ? $"{request.Scheme}://{request.Host}" + : "https://localhost/"; + + var response = new FunctionMetadataResponse + { + Name = functionMetadata.Name, + + // Q: can functionMetadata.ScriptFile be null or empty? + ScriptHref = VirtualFileSystem.FilePathToVfsUri(Path.Combine(config.RootScriptPath, functionMetadata.ScriptFile), baseUrl, config), + ConfigHref = VirtualFileSystem.FilePathToVfsUri(functionMetadataFilePath, baseUrl, config), + ScriptRootPathHref = VirtualFileSystem.FilePathToVfsUri(functionPath, baseUrl, config, isDirectory: true), + TestDataHref = VirtualFileSystem.FilePathToVfsUri(functionMetadata.GetTestDataFilePath(config), baseUrl, config), + Href = GetFunctionHref(functionMetadata.Name, baseUrl), + TestData = await GetTestData(functionMetadata.GetTestDataFilePath(config), config), + Config = await GetFunctionConfig(functionMetadataFilePath, config), + + // Properties below this comment are not present in the kudu version. + IsDirect = functionMetadata.IsDirect, + IsDisabled = functionMetadata.IsDisabled, + IsProxy = functionMetadata.IsProxy + }; + return response; + } + + public static string GetTestDataFilePath(this FunctionMetadata functionMetadata, ScriptHostConfiguration config) => + GetTestDataFilePath(functionMetadata.Name, config); + + public static string GetTestDataFilePath(string functionName, ScriptHostConfiguration config) => + Path.Combine(config.TestDataPath, $"{functionName}.dat"); + + private static async Task GetFunctionConfig(string path, ScriptHostConfiguration config) + { + try + { + if (FileUtility.FileExists(path)) + { + using (var reader = File.OpenText(path)) + { + return JObject.Parse(await reader.ReadToEndAsync()); + } + } + } + catch + { + // no-op + } + + // If there are any errors parsing function.json return an empty object. + // This is current kudu behavior. + // TODO: add an error field that can be used to communicate the JSON parse error. + return new JObject(); + } + + private static async Task GetTestData(string testDataPath, ScriptHostConfiguration config) + { + if (!File.Exists(testDataPath)) + { + await FileUtility.WriteAsync(testDataPath, string.Empty); + } + + return await FileUtility.ReadAsync(testDataPath); + } + + private static Uri GetFunctionHref(string functionName, string baseUrl) => + new Uri($"{baseUrl}/admin/functions/{functionName}"); + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs new file mode 100755 index 0000000000..a16a6ee042 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs @@ -0,0 +1,21 @@ +// 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.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.Management.Models; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class FunctionMetadataResponseExtensions + { + public static string GetFunctionPath(this FunctionMetadataResponse function, ScriptHostConfiguration config) + => VirtualFileSystem.VfsUriToFilePath(function.ScriptRootPathHref, config); + + public static string GetFunctionTestDataFilePath(this FunctionMetadataResponse function, ScriptHostConfiguration config) + => VirtualFileSystem.VfsUriToFilePath(function.TestDataHref, config); + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs new file mode 100755 index 0000000000..e826ce62cb --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs @@ -0,0 +1,21 @@ +// 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.Headers; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class HttpHeadersExtensions + { + public static Dictionary ToCoreHeaders(this HttpHeaders headers, params string[] exclude) + { + return headers + .Where(h => !exclude.Any(e => e.Equals(h.Key, StringComparison.OrdinalIgnoreCase))) + .ToDictionary(k => k.Key, v => new StringValues(v.Value.ToArray())); + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000000..96ce9b3f0f --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs @@ -0,0 +1,33 @@ +// 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.Net.Http; +using System.Net.Http.Headers; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class HttpResponseMessageExtensions + { + public static void SetEntityTagHeader(this HttpResponseMessage httpResponseMessage, EntityTagHeaderValue etag, DateTime lastModified) + { + if (httpResponseMessage.Content == null) + { + httpResponseMessage.Content = new NullContent(); + } + + httpResponseMessage.Headers.ETag = etag; + httpResponseMessage.Content.Headers.LastModified = lastModified; + } + + private class NullContent : StringContent + { + public NullContent() + : base(string.Empty) + { + Headers.ContentType = null; + Headers.ContentLength = null; + } + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs new file mode 100644 index 0000000000..fe822aa240 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs @@ -0,0 +1,31 @@ +// 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.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class IEnumerableTasksExtensions + { + /// + /// Returns a Task that completes when all the tasks in have completed. + /// + /// tasks to be reduced. + /// Task that completes when all tasks are done. + public static Task WhenAll(this IEnumerable collection) + { + return Task.WhenAll(collection); + } + + /// + /// Returns a Task that completes when all the tasks in have completed. + /// + /// tasks to be reduced. + /// Task that completes when all tasks are done. + public static Task WhenAll(this IEnumerable> collection) + { + return Task.WhenAll(collection); + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs new file mode 100644 index 0000000000..cf2b4ef3a1 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions +{ + public static class VfsStringExtensions + { + public static string EscapeHashCharacter(this string str) + { + return str.Replace("#", Uri.EscapeDataString("#")); + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs new file mode 100644 index 0000000000..ad7d825f18 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs @@ -0,0 +1,11 @@ +// 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.WebHost.Extensions +{ + public static class WebHostSettingsExtensions + { + public static ScriptHostConfiguration ToScriptHostConfiguration(this WebHostSettings webHostSettings) => + WebHostResolver.CreateScriptHostConfiguration(webHostSettings); + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs b/src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs new file mode 100644 index 0000000000..4d821342ca --- /dev/null +++ b/src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.StaticFiles; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Helpers +{ + public class MediaTypeMap + { + private static readonly MediaTypeMap _defaultInstance = new MediaTypeMap(); + private static readonly FileExtensionContentTypeProvider _mimeMapping = new FileExtensionContentTypeProvider(); + private readonly ConcurrentDictionary _mediatypeMap = CreateMediaTypeMap(); + private readonly MediaTypeHeaderValue _defaultMediaType = MediaTypeHeaderValue.Parse("application/octet-stream"); + + public static MediaTypeMap Default + { + get { return _defaultInstance; } + } + + public MediaTypeHeaderValue GetMediaType(string fileExtension) + { + if (fileExtension == null) + { + throw new ArgumentNullException(nameof(fileExtension)); + } + + return _mediatypeMap.GetOrAdd(fileExtension, + (extension) => + { + try + { + if (_mimeMapping.TryGetContentType(fileExtension, out string mediaTypeValue)) + { + if (MediaTypeHeaderValue.TryParse(mediaTypeValue, out MediaTypeHeaderValue mediaType)) + { + return mediaType; + } + } + return _defaultMediaType; + } + catch + { + return _defaultMediaType; + } + }); + } + + private static ConcurrentDictionary CreateMediaTypeMap() + { + var dictionary = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + dictionary.TryAdd(".js", MediaTypeHeaderValue.Parse("application/javascript")); + dictionary.TryAdd(".json", MediaTypeHeaderValue.Parse("application/json")); + + // Add media type for markdown + dictionary.TryAdd(".md", MediaTypeHeaderValue.Parse("text/plain")); + + return dictionary; + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs b/src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs new file mode 100755 index 0000000000..c48475d1aa --- /dev/null +++ b/src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs @@ -0,0 +1,158 @@ +// 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.Net; +using System.Net.Http; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Azure.WebJobs.Script.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; +using Microsoft.Azure.WebJobs.Script.WebHost.Models; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Helpers +{ + public static class VfsSpecialFolders + { + private const string SystemDriveFolder = "SystemDrive"; + private const string LocalSiteRootFolder = "LocalSiteRoot"; + + private static string _systemDrivePath; + private static string _localSiteRootPath; + + public static string SystemDrivePath + { + get + { + // only return a system drive for Windows. Unix always assums / as fs root. + if (_systemDrivePath == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _systemDrivePath = Environment.GetEnvironmentVariable(SystemDriveFolder) ?? string.Empty; + } + + return _systemDrivePath; + } + } + + public static string LocalSiteRootPath + { + get + { + if (_localSiteRootPath == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // only light up in Azure env + string tmpPath = Environment.GetEnvironmentVariable("TMP"); + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")) && + !string.IsNullOrEmpty(tmpPath)) + { + _localSiteRootPath = Path.GetDirectoryName(tmpPath); + } + } + + return _localSiteRootPath; + } + // internal for testing purpose + internal set + { + _localSiteRootPath = value; + } + } + + public static IEnumerable GetEntries(string baseAddress, string query) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (!string.IsNullOrEmpty(SystemDrivePath)) + { + var dir = FileUtility.DirectoryInfoFromDirectoryName(SystemDrivePath + Path.DirectorySeparatorChar); + yield return new VfsStatEntry + { + Name = SystemDriveFolder, + MTime = dir.LastWriteTimeUtc, + CRTime = dir.CreationTimeUtc, + Mime = "inode/shortcut", + Href = baseAddress + Uri.EscapeUriString(SystemDriveFolder + VirtualFileSystem.UriSegmentSeparator) + query, + Path = dir.FullName + }; + } + + if (!string.IsNullOrEmpty(LocalSiteRootPath)) + { + var dir = FileUtility.DirectoryInfoFromDirectoryName(LocalSiteRootPath); + yield return new VfsStatEntry + { + Name = LocalSiteRootFolder, + MTime = dir.LastWriteTimeUtc, + CRTime = dir.CreationTimeUtc, + Mime = "inode/shortcut", + Href = baseAddress + Uri.EscapeUriString(LocalSiteRootFolder + VirtualFileSystem.UriSegmentSeparator) + query, + Path = dir.FullName + }; + } + } + } + + public static bool TryHandleRequest(HttpRequest request, string path, out HttpResponseMessage response) + { + response = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + string.Equals(path, SystemDrivePath, StringComparison.OrdinalIgnoreCase)) + { + response = new HttpResponseMessage(HttpStatusCode.TemporaryRedirect); + UriBuilder location = new UriBuilder(request.GetRequestUri()); + location.Path += "/"; + response.Headers.Location = location.Uri; + } + + return response != null; + } + + // this resolves the special folders such as SystemDrive or LocalSiteRoot + public static bool TryParse(string path, out string result) + { + result = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + !string.IsNullOrEmpty(path)) + { + if (string.Equals(path, SystemDriveFolder, StringComparison.OrdinalIgnoreCase) || + path.IndexOf(SystemDriveFolder + VirtualFileSystem.UriSegmentSeparator, StringComparison.OrdinalIgnoreCase) == 0) + { + if (!string.IsNullOrEmpty(SystemDrivePath)) + { + string relativePath = path.Substring(SystemDriveFolder.Length); + if (string.IsNullOrEmpty(relativePath)) + { + result = SystemDrivePath; + } + else + { + result = Path.GetFullPath(SystemDrivePath + relativePath); + } + } + } + else if (string.Equals(path, LocalSiteRootFolder, StringComparison.OrdinalIgnoreCase) || + path.IndexOf(LocalSiteRootFolder + VirtualFileSystem.UriSegmentSeparator, StringComparison.OrdinalIgnoreCase) == 0) + { + if (!string.IsNullOrEmpty(LocalSiteRootPath)) + { + string relativePath = path.Substring(LocalSiteRootFolder.Length); + if (string.IsNullOrEmpty(relativePath)) + { + result = LocalSiteRootPath; + } + else + { + result = Path.GetFullPath(LocalSiteRootPath + relativePath); + } + } + } + } + + return result != null; + } + } +} diff --git a/src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs b/src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs new file mode 100755 index 0000000000..22435bc67c --- /dev/null +++ b/src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs @@ -0,0 +1,21 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.Management.Models; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Management +{ + public interface IWebFunctionsManager + { + Task> GetFunctionsMetadata(HttpRequest request); + + Task<(bool, FunctionMetadataResponse)> TryGetFunction(string name, HttpRequest request); + + Task<(bool, bool, FunctionMetadataResponse)> CreateOrUpdate(string name, FunctionMetadataResponse functionMetadata, HttpRequest request); + + (bool, string) TryDeleteFunction(FunctionMetadataResponse function); + } +} diff --git a/src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs b/src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs new file mode 100644 index 0000000000..6d9cd99892 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs @@ -0,0 +1,630 @@ +// 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 System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.Config; +using Microsoft.Azure.WebJobs.Script.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost.Helpers; +using Microsoft.Azure.WebJobs.Script.WebHost.Models; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Management +{ + public class VirtualFileSystem + { + public const char UriSegmentSeparator = '/'; + private const string DirectoryEnumerationSearchPattern = "*"; + private const string DummyRazorExtension = ".func777"; + + private static readonly char[] _uriSegmentSeparator = new char[] { UriSegmentSeparator }; + private static readonly MediaTypeHeaderValue _directoryMediaType = MediaTypeHeaderValue.Parse("inode/directory"); + + protected const int BufferSize = 32 * 1024; + private readonly ScriptHostConfiguration _config; + + public VirtualFileSystem(WebHostSettings settings) + { + _config = settings.ToScriptHostConfiguration(); + MediaTypeMap = MediaTypeMap.Default; + } + + protected string RootPath + { + get + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath) ?? Path.GetFullPath(_config.RootScriptPath) + : Path.DirectorySeparatorChar.ToString(); + } + } + + protected MediaTypeMap MediaTypeMap { get; private set; } + + public virtual Task GetItem(HttpRequest request) + { + string localFilePath = GetLocalFilePath(request); + + if (VfsSpecialFolders.TryHandleRequest(request, localFilePath, out HttpResponseMessage response)) + { + return Task.FromResult(response); + } + + var info = FileUtility.DirectoryInfoFromDirectoryName(localFilePath); + + if (info.Attributes < 0) + { + var notFoundResponse = CreateResponse(HttpStatusCode.NotFound, string.Format("'{0}' not found.", info.FullName)); + return Task.FromResult(notFoundResponse); + } + else if ((info.Attributes & FileAttributes.Directory) != 0) + { + // If request URI does NOT end in a "/" then redirect to one that does + var uri = request.GetRequestUri(); + if (!uri.AbsolutePath.EndsWith("/")) + { + var redirectResponse = CreateResponse(HttpStatusCode.TemporaryRedirect); + var location = new UriBuilder(uri); + location.Path += "/"; + redirectResponse.Headers.Location = location.Uri; + return Task.FromResult(redirectResponse); + } + else + { + return CreateDirectoryGetResponse(request, info, localFilePath); + } + } + else + { + // If request URI ends in a "/" then redirect to one that does not + if (localFilePath[localFilePath.Length - 1] == Path.DirectorySeparatorChar) + { + HttpResponseMessage redirectResponse = CreateResponse(HttpStatusCode.TemporaryRedirect); + UriBuilder location = new UriBuilder(request.GetRequestUri()); + location.Path = location.Path.TrimEnd(_uriSegmentSeparator); + redirectResponse.Headers.Location = location.Uri; + return Task.FromResult(redirectResponse); + } + + // We are ready to get the file + return CreateItemGetResponse(request, info, localFilePath); + } + } + + public virtual Task PutItem(HttpRequest request) + { + var localFilePath = GetLocalFilePath(request); + + if (VfsSpecialFolders.TryHandleRequest(request, localFilePath, out HttpResponseMessage response)) + { + return Task.FromResult(response); + } + + var info = FileUtility.DirectoryInfoFromDirectoryName(localFilePath); + var itemExists = info.Attributes >= 0; + + if (itemExists && (info.Attributes & FileAttributes.Directory) != 0) + { + return CreateDirectoryPutResponse(request, info, localFilePath); + } + else + { + // If request URI ends in a "/" then attempt to create the directory. + if (localFilePath[localFilePath.Length - 1] == Path.DirectorySeparatorChar) + { + return CreateDirectoryPutResponse(request, info, localFilePath); + } + + // We are ready to update the file + return CreateItemPutResponse(request, info, localFilePath, itemExists); + } + } + + public virtual Task DeleteItem(HttpRequest request, bool recursive = false) + { + string localFilePath = GetLocalFilePath(request); + + if (VfsSpecialFolders.TryHandleRequest(request, localFilePath, out HttpResponseMessage response)) + { + return Task.FromResult(response); + } + + var dirInfo = FileUtility.DirectoryInfoFromDirectoryName(localFilePath); + + if (dirInfo.Attributes < 0) + { + var notFoundResponse = CreateResponse(HttpStatusCode.NotFound, string.Format("'{0}' not found.", dirInfo.FullName)); + return Task.FromResult(notFoundResponse); + } + else if ((dirInfo.Attributes & FileAttributes.Directory) != 0) + { + try + { + dirInfo.Delete(recursive); + } + catch (Exception ex) + { + // TODO: log ex + var conflictDirectoryResponse = CreateResponse(HttpStatusCode.Conflict, ex); + return Task.FromResult(conflictDirectoryResponse); + } + + // Delete directory succeeded. + var successResponse = CreateResponse(HttpStatusCode.OK); + return Task.FromResult(successResponse); + } + else + { + // If request URI ends in a "/" then redirect to one that does not + if (localFilePath[localFilePath.Length - 1] == Path.DirectorySeparatorChar) + { + var redirectResponse = CreateResponse(HttpStatusCode.TemporaryRedirect); + var location = new UriBuilder(request.GetRequestUri()); + location.Path = location.Path.TrimEnd(_uriSegmentSeparator); + redirectResponse.Headers.Location = location.Uri; + return Task.FromResult(redirectResponse); + } + + // We are ready to delete the file + var fileInfo = FileUtility.FileInfoFromFileName(localFilePath); + return CreateFileDeleteResponse(request, fileInfo); + } + } + + public static Uri FilePathToVfsUri(string filePath, string baseUrl, ScriptHostConfiguration config, bool isDirectory = false) + { + var home = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath) ?? config.RootScriptPath + : Path.DirectorySeparatorChar.ToString(); + + filePath = filePath + .Substring(home.Length) + .Trim('\\', '/') + .Replace("\\", "/"); + + return new Uri($"{baseUrl}/admin/vfs/{filePath}{(isDirectory ? "/" : string.Empty)}"); + } + + public static string VfsUriToFilePath(Uri uri, ScriptHostConfiguration config, bool isDirectory = false) + { + var home = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath) ?? config.RootScriptPath + : Path.DirectorySeparatorChar.ToString(); + + var filePath = uri.AbsolutePath.Split("/admin/vfs").LastOrDefault(); + filePath = string.IsNullOrEmpty(filePath) + ? home + : Path.Combine(home, filePath.TrimStart('/')); + + return filePath.Replace('/', Path.DirectorySeparatorChar); + } + + protected virtual Task CreateDirectoryGetResponse(HttpRequest request, DirectoryInfoBase info, string localFilePath) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + + try + { + // Enumerate directory + var directory = GetDirectoryResponse(request, info.GetFileSystemInfos()); + var successDirectoryResponse = CreateResponse(HttpStatusCode.OK, directory); + return Task.FromResult(successDirectoryResponse); + } + catch (Exception e) + { + // TODO: log + HttpResponseMessage errorResponse = CreateResponse(HttpStatusCode.InternalServerError, e.Message); + return Task.FromResult(errorResponse); + } + } + + protected Task CreateItemGetResponse(HttpRequest request, FileSystemInfoBase info, string localFilePath) + { + // Get current etag + var currentEtag = CreateEntityTag(info); + var lastModified = info.LastWriteTimeUtc; + + // Check whether we have a range request (taking If-Range condition into account) + bool isRangeRequest = IsRangeRequest(request, currentEtag); + + // Check whether we have a conditional If-None-Match request + // Unless it is a range request (see RFC2616 sec 14.35.2 Range Retrieval Requests) + if (!isRangeRequest && IsIfNoneMatchRequest(request, currentEtag.ToSystemETag())) + { + var notModifiedResponse = CreateResponse(HttpStatusCode.NotModified); + notModifiedResponse.SetEntityTagHeader(currentEtag.ToSystemETag(), lastModified); + return Task.FromResult(notModifiedResponse); + } + + // Generate file response + Stream fileStream = null; + try + { + fileStream = GetFileReadStream(localFilePath); + var mediaType = MediaTypeMap.GetMediaType(info.Extension); + var successFileResponse = CreateResponse(isRangeRequest ? HttpStatusCode.PartialContent : HttpStatusCode.OK); + + if (isRangeRequest) + { + var typedHeaders = request.GetTypedHeaders(); + var rangeHeader = new RangeHeaderValue + { + Unit = typedHeaders.Range.Unit.Value + }; + + foreach (var range in typedHeaders.Range.Ranges) + { + rangeHeader.Ranges.Add(new RangeItemHeaderValue(range.From, range.To)); + } + + successFileResponse.Content = new ByteRangeStreamContent(fileStream, rangeHeader, mediaType, BufferSize); + } + else + { + successFileResponse.Content = new StreamContent(fileStream, BufferSize); + successFileResponse.Content.Headers.ContentType = mediaType; + } + + // Set etag for the file + successFileResponse.SetEntityTagHeader(currentEtag.ToSystemETag(), lastModified); + return Task.FromResult(successFileResponse); + } + catch (InvalidByteRangeException invalidByteRangeException) + { + // The range request had no overlap with the current extend of the resource so generate a 416 (Requested Range Not Satisfiable) + // including a Content-Range header with the current size. + // TODO: log? + var invalidByteRangeResponse = CreateResponse(HttpStatusCode.RequestedRangeNotSatisfiable, invalidByteRangeException); + if (fileStream != null) + { + fileStream.Close(); + } + return Task.FromResult(invalidByteRangeResponse); + } + catch (Exception ex) + { + // Could not read the file + // TODO: log? + var errorResponse = CreateResponse(HttpStatusCode.NotFound, ex); + if (fileStream != null) + { + fileStream.Close(); + } + return Task.FromResult(errorResponse); + } + } + + protected Task CreateDirectoryPutResponse(HttpRequest request, DirectoryInfoBase info, string localFilePath) + { + if (info != null && info.Exists) + { + // Return a conflict result + var conflictDirectoryResponse = CreateResponse(HttpStatusCode.Conflict); + return Task.FromResult(conflictDirectoryResponse); + } + + try + { + info.Create(); + } + catch (IOException ex) + { + // TODO: log ex + HttpResponseMessage conflictDirectoryResponse = CreateResponse(HttpStatusCode.Conflict); + return Task.FromResult(conflictDirectoryResponse); + } + + // Return 201 Created response + var successFileResponse = CreateResponse(HttpStatusCode.Created); + return Task.FromResult(successFileResponse); + } + + protected async Task CreateItemPutResponse(HttpRequest request, FileSystemInfoBase info, string localFilePath, bool itemExists) + { + // Check that we have a matching conditional If-Match request for existing resources + if (itemExists) + { + // Get current etag + var currentEtag = CreateEntityTag(info); + var typedHeaders = request.GetTypedHeaders(); + // Existing resources require an etag to be updated. + if (typedHeaders.IfMatch == null) + { + var missingIfMatchResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "missing If-Match"); + return missingIfMatchResponse; + } + + bool isMatch = false; + foreach (var etag in typedHeaders.IfMatch) + { + if (currentEtag.Equals(etag) || etag == Microsoft.Net.Http.Headers.EntityTagHeaderValue.Any) + { + isMatch = true; + break; + } + } + + if (!isMatch) + { + var conflictFileResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "Etag mismatch"); + conflictFileResponse.Headers.ETag = currentEtag.ToSystemETag(); + return conflictFileResponse; + } + } + + // Save file + try + { + using (Stream fileStream = GetFileWriteStream(localFilePath, fileExists: itemExists)) + { + try + { + await request.Body.CopyToAsync(fileStream); + } + catch (Exception ex) + { + // TODO: log ex + var conflictResponse = CreateResponse(HttpStatusCode.Conflict, ex); + return conflictResponse; + } + } + + // Return either 204 No Content or 201 Created response + var successFileResponse = CreateResponse(itemExists ? HttpStatusCode.NoContent : HttpStatusCode.Created); + + // Set updated etag for the file + info.Refresh(); + successFileResponse.SetEntityTagHeader(CreateEntityTag(info).ToSystemETag(), info.LastWriteTimeUtc); + return successFileResponse; + } + catch (Exception ex) + { + // TODO: log ex + var errorResponse = CreateResponse(HttpStatusCode.Conflict, ex); + return errorResponse; + } + } + + protected Task CreateFileDeleteResponse(HttpRequest request, FileInfoBase info) + { + // Existing resources require an etag to be updated. + var typedHeaders = request.GetTypedHeaders(); + if (typedHeaders.IfMatch == null) + { + var conflictDirectoryResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "Missing If-Match"); + return Task.FromResult(conflictDirectoryResponse); + } + + // Get current etag + var currentEtag = CreateEntityTag(info); + var isMatch = typedHeaders.IfMatch.Any(etag => etag == Microsoft.Net.Http.Headers.EntityTagHeaderValue.Any || currentEtag.Equals(etag)); + + if (!isMatch) + { + var conflictFileResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "Etag mismatch"); + conflictFileResponse.Headers.ETag = currentEtag.ToSystemETag(); + return Task.FromResult(conflictFileResponse); + } + + try + { + using (Stream fileStream = GetFileDeleteStream(info)) + { + info.Delete(); + } + var successResponse = CreateResponse(HttpStatusCode.OK); + return Task.FromResult(successResponse); + } + catch (Exception e) + { + // Could not delete the file + // TODO: log ex + var notFoundResponse = CreateResponse(HttpStatusCode.NotFound, e); + return Task.FromResult(notFoundResponse); + } + } + + /// + /// Indicates whether this is a conditional range request containing an + /// If-Range header with a matching etag and a Range header indicating the + /// desired ranges + /// + protected bool IsRangeRequest(HttpRequest request, Net.Http.Headers.EntityTagHeaderValue currentEtag) + { + var typedHeaders = request.GetTypedHeaders(); + if (typedHeaders.Range == null) + { + return false; + } + if (typedHeaders.IfRange != null) + { + return typedHeaders.IfRange.EntityTag.Equals(currentEtag); + } + return true; + } + + /// + /// Indicates whether this is a If-None-Match request with a matching etag. + /// + protected bool IsIfNoneMatchRequest(HttpRequest request, EntityTagHeaderValue currentEtag) + { + var typedHeaders = request.GetTypedHeaders(); + return currentEtag != null && typedHeaders.IfNoneMatch != null && + typedHeaders.IfNoneMatch.Any(entityTag => currentEtag.Equals(entityTag)); + } + + /// + /// Provides a common way for opening a file stream for shared reading from a file. + /// + protected static Stream GetFileReadStream(string localFilePath) + { + if (localFilePath == null) + { + throw new ArgumentNullException(nameof(localFilePath)); + } + + // Open file exclusively for read-sharing + return new FileStream(localFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, BufferSize, useAsync: true); + } + + /// + /// Provides a common way for opening a file stream for writing exclusively to a file. + /// + protected static Stream GetFileWriteStream(string localFilePath, bool fileExists) + { + if (localFilePath == null) + { + throw new ArgumentNullException(nameof(localFilePath)); + } + + // Create path if item doesn't already exist + if (!fileExists) + { + Directory.CreateDirectory(Path.GetDirectoryName(localFilePath)); + } + + // Open file exclusively for write without any sharing + return new FileStream(localFilePath, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, useAsync: true); + } + + /// + /// Provides a common way for opening a file stream for exclusively deleting the file. + /// + private static Stream GetFileDeleteStream(FileInfoBase file) + { + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + + // Open file exclusively for delete sharing only + return file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + } + + /// + /// Create unique etag based on the last modified UTC time + /// + private static Microsoft.Net.Http.Headers.EntityTagHeaderValue CreateEntityTag(FileSystemInfoBase sysInfo) + { + if (sysInfo == null) + { + throw new ArgumentNullException(nameof(sysInfo)); + } + + var etag = BitConverter.GetBytes(sysInfo.LastWriteTimeUtc.Ticks); + + var result = new StringBuilder(2 + (etag.Length * 2)); + result.Append("\""); + foreach (byte b in etag) + { + result.AppendFormat("{0:x2}", b); + } + result.Append("\""); + return new Microsoft.Net.Http.Headers.EntityTagHeaderValue(result.ToString()); + } + + // internal for testing purpose + internal string GetLocalFilePath(HttpRequest request) + { + // Restore the original extension if we had added a dummy + // See comment in TraceModule.OnBeginRequest + string result = GetOriginalLocalFilePath(request); + if (result.EndsWith(DummyRazorExtension, StringComparison.Ordinal)) + { + result = result.Substring(0, result.Length - DummyRazorExtension.Length); + } + + return result; + } + + private string GetOriginalLocalFilePath(HttpRequest request) + { + string result = null; + PathString path = null; + if (request.Path.StartsWithSegments("/admin/vfs", out path) || + request.Path.StartsWithSegments("/admin/zip", out path)) + { + if (VfsSpecialFolders.TryParse(path, out result)) + { + return result; + } + } + + result = RootPath; + if (path != null && path.HasValue) + { + result = Path.GetFullPath(Path.Combine(result, path.Value.TrimStart('/'))); + } + else + { + string reqUri = request.GetRequestUri().AbsoluteUri.Split('?').First(); + if (reqUri[reqUri.Length - 1] == UriSegmentSeparator) + { + result = Path.GetFullPath(result + Path.DirectorySeparatorChar); + } + } + return result; + } + + private IEnumerable GetDirectoryResponse(HttpRequest request, FileSystemInfoBase[] infos) + { + var uri = request.GetRequestUri(); + string baseAddress = uri.AbsoluteUri.Split('?').First(); + string query = uri.Query; + foreach (FileSystemInfoBase fileSysInfo in infos) + { + bool isDirectory = (fileSysInfo.Attributes & FileAttributes.Directory) != 0; + string mime = isDirectory ? _directoryMediaType.ToString() : MediaTypeMap.GetMediaType(fileSysInfo.Extension).ToString(); + string unescapedHref = isDirectory ? fileSysInfo.Name + UriSegmentSeparator : fileSysInfo.Name; + long size = isDirectory ? 0 : ((FileInfoBase)fileSysInfo).Length; + + yield return new VfsStatEntry + { + Name = fileSysInfo.Name, + MTime = fileSysInfo.LastWriteTimeUtc, + CRTime = fileSysInfo.CreationTimeUtc, + Mime = mime, + Size = size, + Href = (baseAddress + Uri.EscapeUriString(unescapedHref) + query).EscapeHashCharacter(), + Path = fileSysInfo.FullName + }; + } + + // add special folders when requesting Root url + // TODO: ahmels + //var routeData = request.HttpContext.GetRouteData(); + //if (routeData != null && string.IsNullOrEmpty(routeData.Values["path"] as string)) + //{ + // foreach (var entry in VfsSpecialFolders.GetEntries(baseAddress, query)) + // { + // yield return entry; + // } + //} + } + + protected HttpResponseMessage CreateResponse(HttpStatusCode statusCode, object payload = null) + { + var response = new HttpResponseMessage(statusCode); + if (payload != null) + { + var content = payload is string ? payload as string : JsonConvert.SerializeObject(payload); + response.Content = new StringContent(content, Encoding.UTF8, "application/json"); + } + return response; + } + } +} diff --git a/src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs b/src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs new file mode 100755 index 0000000000..18a64c8f75 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs @@ -0,0 +1,163 @@ +// 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.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script; +using Microsoft.Azure.WebJobs.Script.Management.Models; +using Microsoft.Azure.WebJobs.Script.WebHost; +using Microsoft.Azure.WebJobs.Script.WebHost.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost.Helpers; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Management +{ + public class WebFunctionsManager : IWebFunctionsManager + { + private readonly ScriptHostConfiguration _config; + private readonly ILogger _logger; + + public WebFunctionsManager(WebHostSettings webSettings, ILoggerFactory loggerFactory) + { + _config = WebHostResolver.CreateScriptHostConfiguration(webSettings); + _logger = loggerFactory?.CreateLogger(ScriptConstants.LogCategoryKeysController); + } + + /// + /// Calls into ScriptHost to retrieve list of FunctionMetadata + /// and maps them to FunctionMetadataResponse. + /// + /// Current HttpRequest for figuring out baseUrl + /// collection of FunctionMetadataResponse + public async Task> GetFunctionsMetadata(HttpRequest request) + { + return await ScriptHost.ReadFunctionsMetadata(FileUtility.EnumerateDirectories(_config.RootScriptPath), _logger, new Dictionary>()) + .Select(fm => fm.ToFunctionMetadataResponse(request, _config)) + .WhenAll(); + } + + /// + /// It handles creating a new function or updating an existing one. + /// It attempts to clean left over artifacts from a possible previous function with the same name + /// if config is changed, then `configChanged` is set to true so the caller can call SyncTriggers if needed. + /// + /// name of the function to be created + /// in case of update for function.json + /// Current HttpRequest. + /// (success, configChanged, functionMetadataResult) + public async Task<(bool, bool, FunctionMetadataResponse)> CreateOrUpdate(string name, FunctionMetadataResponse functionMetadata, HttpRequest request) + { + var configChanged = false; + var functionDir = Path.Combine(_config.RootScriptPath, name); + + // Make sure the function folder exists + if (!FileUtility.DirectoryExists(functionDir)) + { + // Cleanup any leftover artifacts from a function with the same name before. + DeleteFunctionArtifacts(functionMetadata); + Directory.CreateDirectory(functionDir); + } + + string newConfig = null; + string configPath = Path.Combine(functionDir, ScriptConstants.FunctionMetadataFileName); + string dataFilePath = FunctionMetadataExtensions.GetTestDataFilePath(name, _config); + + // If files are included, write them out + if (functionMetadata?.Files != null) + { + // If the config is passed in the file collection, save it and don't process it as a file + if (functionMetadata.Files.TryGetValue(ScriptConstants.FunctionMetadataFileName, out newConfig)) + { + functionMetadata.Files.Remove(ScriptConstants.FunctionMetadataFileName); + } + + // Delete all existing files in the directory. This will also delete current function.json, but it gets recreated below + FileUtility.DeleteDirectoryContentsSafe(functionDir); + + await functionMetadata + .Files + .Select(e => FileUtility.WriteAsync(Path.Combine(functionDir, e.Key), e.Value)) + .WhenAll(); + } + + // Get the config (if it was not already passed in as a file) + if (newConfig == null && functionMetadata?.Config != null) + { + newConfig = JsonConvert.SerializeObject(functionMetadata?.Config, Formatting.Indented); + } + + // Get the current config, if any + string currentConfig = null; + if (FileUtility.FileExists(configPath)) + { + currentConfig = await FileUtility.ReadAsync(configPath); + } + + // Save the file and set changed flag is it has changed. This helps optimize the syncTriggers call + if (newConfig != currentConfig) + { + await FileUtility.WriteAsync(configPath, newConfig); + configChanged = true; + } + + if (functionMetadata.TestData != null) + { + await FileUtility.WriteAsync(dataFilePath, functionMetadata.TestData); + } + + (var success, var functionMetadataResult) = await TryGetFunction(name, request); // test_data took from incoming request, it will not exceed the limit + return (success, configChanged, functionMetadataResult); + } + + /// + /// maps a functionName to its FunctionMetadataResponse + /// + /// Function name to retrieve + /// Current HttpRequest + /// (success, FunctionMetadataResponse) + public async Task<(bool, FunctionMetadataResponse)> TryGetFunction(string name, HttpRequest request) + { + var functionMetadata = ScriptHost.ReadFunctionMetadata(Path.Combine(_config.RootScriptPath, name), _logger, new Dictionary>()); + if (functionMetadata != null) + { + return (true, await functionMetadata.ToFunctionMetadataResponse(request, _config)); + } + else + { + return (false, null); + } + } + + /// + /// Delete a function and all it's artifacts. + /// + /// Function to be deleted + /// (success, errorMessage) + public (bool, string) TryDeleteFunction(FunctionMetadataResponse function) + { + try + { + FileUtility.DeleteDirectoryContentsSafe(function.GetFunctionPath(_config)); + DeleteFunctionArtifacts(function); + return (true, string.Empty); + } + catch (Exception e) + { + return (false, e.ToString()); + } + } + + private void DeleteFunctionArtifacts(FunctionMetadataResponse function) + { + // TODO: clear secrets + // TODO: clear logs + FileUtility.DeleteFileSafe(function.GetFunctionTestDataFilePath(_config)); + } + } +} diff --git a/src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs b/src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs new file mode 100755 index 0000000000..cd4164864d --- /dev/null +++ b/src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs @@ -0,0 +1,115 @@ +// 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.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.WebHost.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; +using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Middleware +{ + public class VirtualFileSystemMiddleware : IMiddleware + { + private readonly VirtualFileSystem _vfs; + + public VirtualFileSystemMiddleware(VirtualFileSystem vfs) + { + _vfs = vfs; + } + + /// + /// A request is a vfs request if it starts with /admin/zip or /admin/vfs + /// + /// Current HttpContext + /// IsVirtualFileSystemRequest + public static bool IsVirtualFileSystemRequest(HttpContext context) + { + return context.Request.Path.StartsWithSegments("/admin/vfs"); + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate _) + { + var authorized = await AuthenticateAndAuthorize(context); + + if (!authorized) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + } + else + { + await InternalInvokeAsync(context); + } + } + + private async Task InternalInvokeAsync(HttpContext context) + { + // choose the right instance to use. + HttpResponseMessage response = null; + try + { + switch (context.Request.Method.ToLowerInvariant()) + { + case "get": + response = await _vfs.GetItem(context.Request); + break; + + case "put": + response = await _vfs.PutItem(context.Request); + break; + + case "delete": + response = await _vfs.DeleteItem(context.Request); + break; + + default: + // VFS only supports GET, PUT, and DELETE + response = new HttpResponseMessage(System.Net.HttpStatusCode.MethodNotAllowed); + break; + } + + context.Response.StatusCode = (int)response.StatusCode; + + // write response headers + context.Response.Headers.AddRange(response.Headers.ToCoreHeaders()); + + // This is to handle NullContent which != null, but has ContentLength of null. + if (response.Content != null && response.Content.Headers.ContentLength != null) + { + // Exclude content length to let ASP.NET Core take care of setting that based on the stream size. + context.Response.Headers.AddRange(response.Content.Headers.ToCoreHeaders("Content-Length")); + await response.Content.CopyToAsync(context.Response.Body); + } + response.Dispose(); + } + catch (Exception e) + { + if (response != null) + { + response.Dispose(); + } + + await context.Response.WriteAsync(e.Message); + } + } + + private async Task AuthenticateAndAuthorize(HttpContext context) + { + var authorizationPolicyProvider = context.RequestServices.GetRequiredService(); + var policyEvaluator = context.RequestServices.GetRequiredService(); + + var policy = await authorizationPolicyProvider.GetPolicyAsync(PolicyNames.AdminAuthLevel); + var authenticateResult = await policyEvaluator.AuthenticateAsync(policy, context); + + // For admin, resource is null. + var authorizeResult = await policyEvaluator.AuthorizeAsync(policy, authenticateResult, context, resource: null); + + return authorizeResult.Succeeded; + } + } +} diff --git a/src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs b/src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs new file mode 100755 index 0000000000..6135d76b95 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs @@ -0,0 +1,87 @@ +// 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 Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Script.Management.Models +{ + public class FunctionMetadataResponse + { + /// + /// Gets or sets the function name + /// + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + + /// + /// Gets or sets the script folder url + /// + [JsonProperty(PropertyName = "script_root_path_href")] + public Uri ScriptRootPathHref { get; set; } + + /// + /// Gets or sets script file url + /// + [JsonProperty(PropertyName = "script_href")] + public Uri ScriptHref { get; set; } + + /// + /// Gets or sets function config file url + /// + [JsonProperty(PropertyName = "config_href")] + public Uri ConfigHref { get; set; } + + /// + /// Gets or sets function test data url + /// + [JsonProperty(PropertyName = "test_data_href")] + public Uri TestDataHref { get; set; } + + /// + /// Gets or sets current function self link + /// + [JsonProperty(PropertyName = "href")] + public Uri Href { get; set; } + + /// + /// Gets or sets function config json + /// + [JsonProperty(PropertyName = "config")] + public JObject Config { get; set; } + + /// + /// Gets or sets flat list of files and their content. + /// The dictionary is fileName => fileContent + /// + [JsonProperty(PropertyName = "files")] + public IDictionary Files { get; set; } + + /// + /// Gets or sets the test data string. + /// This is only used for the UI and only supports string inputs. + /// + [JsonProperty(PropertyName = "test_data")] + public string TestData { get; set; } + + /// + /// Gets or sets a value indicating whether the function is disabled + /// + [JsonProperty(PropertyName = "isDisabled")] + public bool IsDisabled { get; set; } + + /// + /// Gets or sets a value indicating whether the function is direct. + /// + [JsonProperty(PropertyName = "isDirect")] + public bool IsDirect { get; set; } + + /// + /// Gets or sets a value indicating whether the function is a proxy function. + /// + [JsonProperty(PropertyName = "isProxy")] + public bool IsProxy { get; set; } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs b/src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs new file mode 100644 index 0000000000..b4a5688681 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Models +{ + /// + /// Represents a directory structure. Used by to browse + /// a Kudu file system or the git repository. + /// + public class VfsStatEntry + { + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + + [JsonProperty(PropertyName = "size")] + public long Size { get; set; } + + [JsonProperty(PropertyName = "mtime")] + public DateTimeOffset MTime { get; set; } + + [JsonProperty(PropertyName = "crtime")] + public DateTimeOffset CRTime { get; set; } + + [JsonProperty(PropertyName = "mime")] + public string Mime { get; set; } + + [JsonProperty(PropertyName = "href")] + public string Href { get; set; } + + [JsonProperty(PropertyName = "path")] + public string Path { get; set; } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/WebHostResolver.cs b/src/WebJobs.Script.WebHost/WebHostResolver.cs index a9f44c8681..c8a983adc0 100644 --- a/src/WebJobs.Script.WebHost/WebHostResolver.cs +++ b/src/WebJobs.Script.WebHost/WebHostResolver.cs @@ -117,7 +117,7 @@ internal void EnsureInitialized(WebHostSettings settings) _activeHostManager = new WebScriptHostManager(_activeScriptHostConfig, _secretManagerFactory, _eventManager, _settingsManager, settings, _router, loggerProviderFactory: _loggerProviderFactory, loggerFactory: _loggerFactory); //_activeReceiverManager = new WebHookReceiverManager(_activeHostManager.SecretManager); - InitializeFileSystem(_settingsManager.FileSystemIsReadOnly); + InitializeFileSystem(settings, _settingsManager.FileSystemIsReadOnly); if (_standbyHostManager != null) { @@ -153,7 +153,7 @@ internal void EnsureInitialized(WebHostSettings settings) _router, loggerProviderFactory: _loggerProviderFactory, loggerFactory: _loggerFactory); // _standbyReceiverManager = new WebHookReceiverManager(_standbyHostManager.SecretManager); - InitializeFileSystem(_settingsManager.FileSystemIsReadOnly); + InitializeFileSystem(settings, _settingsManager.FileSystemIsReadOnly); StandbyManager.Initialize(_standbyScriptHostConfig, logger); // start a background timer to identify when specialization happens @@ -186,12 +186,13 @@ internal static WebHostSettings CreateStandbySettings(WebHostSettings settings) internal static ScriptHostConfiguration CreateScriptHostConfiguration(WebHostSettings settings, bool inStandbyMode = false) { - var scriptHostConfig = new ScriptHostConfiguration + var scriptHostConfig = new ScriptHostConfiguration() { RootScriptPath = settings.ScriptPath, RootLogPath = settings.LogPath, FileLoggingMode = FileLoggingMode.DebugOnly, - IsSelfHost = settings.IsSelfHost + IsSelfHost = settings.IsSelfHost, + TestDataPath = settings.TestDataPath }; if (inStandbyMode) @@ -245,10 +246,11 @@ private void OnSpecializationTimerTick(object state) _activeHostManager?.RunAsync(CancellationToken.None); } - private static void InitializeFileSystem(bool readOnlyFileSystem) + private static void InitializeFileSystem(WebHostSettings settings, bool readOnlyFileSystem) { if (ScriptSettingsManager.Instance.IsAzureEnvironment) { + // When running on Azure, we kick this off on the background Task.Run(() => { string home = ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath); @@ -265,11 +267,14 @@ private static void InitializeFileSystem(bool readOnlyFileSystem) } } - string toolsPath = Path.Combine(home, @"site\tools"); if (!readOnlyFileSystem) { + string toolsPath = Path.Combine(home, @"site\tools"); // Create the tools folder if it doesn't exist Directory.CreateDirectory(toolsPath); + + // Create the test data folder + Directory.CreateDirectory(settings.TestDataPath); } var folders = new List(); @@ -288,6 +293,19 @@ private static void InitializeFileSystem(bool readOnlyFileSystem) } }); } + else + { + // Ensure we have our scripts directory in non-Azure scenarios + if (!string.IsNullOrEmpty(settings.ScriptPath)) + { + Directory.CreateDirectory(settings.ScriptPath); + } + + if (!string.IsNullOrEmpty(settings.TestDataPath)) + { + Directory.CreateDirectory(settings.TestDataPath); + } + } } public void Dispose() @@ -302,4 +320,4 @@ public void Dispose() //_activeReceiverManager?.Dispose(); } } -} \ No newline at end of file +} diff --git a/src/WebJobs.Script.WebHost/WebHostSettings.cs b/src/WebJobs.Script.WebHost/WebHostSettings.cs index 3d4c276f2a..e42abae401 100644 --- a/src/WebJobs.Script.WebHost/WebHostSettings.cs +++ b/src/WebJobs.Script.WebHost/WebHostSettings.cs @@ -22,6 +22,13 @@ public class WebHostSettings public string SecretsPath { get; set; } + /// + /// Gets or sets the path for storing test data + /// This is used for function management operations where the client (portal) + /// saves the last invocation test data for a given function + /// + public string TestDataPath { get; set; } + /// /// Gets or sets a value indicating whether authentication/authorization /// should be disabled. Useful for local debugging or CLI scenarios. @@ -40,12 +47,14 @@ internal static WebHostSettings CreateDefault(ScriptSettingsManager settingsMana string home = settingsManager.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath); settings.ScriptPath = Path.Combine(home, "site", "wwwroot"); settings.LogPath = Path.Combine(home, "LogFiles", "Application", "Functions"); - settings.SecretsPath = Path.Combine(home, @"data", "Functions", "secrets"); + settings.SecretsPath = Path.Combine(home, "data", "Functions", "secrets"); + settings.TestDataPath = Path.Combine(home, "data", "Functions", "sampledata"); } else { settings.ScriptPath = settingsManager.GetSetting(EnvironmentSettingNames.AzureWebJobsScriptRoot); settings.LogPath = Path.Combine(Path.GetTempPath(), @"Functions"); + settings.TestDataPath = Path.Combine(Path.GetTempPath(), @"FunctionsData"); // TODO: Revisit. We'll likely have to take an instance of an IHostingEnvironment here settings.SecretsPath = Path.Combine(AppContext.BaseDirectory, "Secrets"); diff --git a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs index dab136f3a6..d87720d66a 100644 --- a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs +++ b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.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; @@ -27,6 +27,9 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder config.UseMiddleware(); }); + // Register /admin/vfs, and /admin/zip to the VirtualFileSystem middleware. + builder.UseWhen(VirtualFileSystemMiddleware.IsVirtualFileSystemRequest, config => config.UseMiddleware()); + // Ensure the HTTP binding routing is registered after all middleware builder.UseHttpBindingRouting(applicationLifetime, routes); @@ -40,4 +43,4 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder return builder; } } -} +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs b/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs index e61ca77700..c23dbc56c4 100644 --- a/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs +++ b/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs @@ -13,6 +13,8 @@ using Microsoft.Azure.WebJobs.Script.Diagnostics; using Microsoft.Azure.WebJobs.Script.Eventing; using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; +using Microsoft.Azure.WebJobs.Script.WebHost.Middleware; using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization; using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies; using Microsoft.Extensions.Configuration; @@ -97,6 +99,9 @@ public static IServiceProvider AddWebJobsScriptHost(this IServiceCollection serv // The services below need to be scoped to a pseudo-tenant (warm/specialized environment) builder.Register(c => c.Resolve().GetWebScriptHostManager()).ExternallyOwned(); builder.Register(c => c.Resolve().GetSecretManager()).ExternallyOwned(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType(); + builder.RegisterType(); // Populate the container builder with registered services. // Doing this here will cause any services registered in the service collection to @@ -127,4 +132,4 @@ private static ILoggerFactory CreateLoggerFactory(string hostInstanceId, ScriptS return loggerFactory; } } -} +} \ No newline at end of file diff --git a/src/WebJobs.Script/Config/ScriptHostConfiguration.cs b/src/WebJobs.Script/Config/ScriptHostConfiguration.cs index 6b590bd541..93b78d5ba9 100644 --- a/src/WebJobs.Script/Config/ScriptHostConfiguration.cs +++ b/src/WebJobs.Script/Config/ScriptHostConfiguration.cs @@ -19,6 +19,7 @@ public ScriptHostConfiguration() FileLoggingMode = FileLoggingMode.Never; RootScriptPath = Environment.CurrentDirectory; RootLogPath = Path.Combine(Path.GetTempPath(), "Functions"); + TestDataPath = Path.Combine(Path.GetTempPath(), "FunctionsData"); LogFilter = new LogCategoryFilter(); HostHealthMonitor = new HostHealthMonitorConfiguration(); } @@ -38,6 +39,11 @@ public ScriptHostConfiguration() /// public string RootLogPath { get; set; } + /// + /// Gets or sets the root path for sample test data. + /// + public string TestDataPath { get; set; } + /// /// Gets or sets a value indicating whether the should /// monitor file for changes (default is true). When set to true, the host will diff --git a/src/WebJobs.Script/Extensions/FileUtility.cs b/src/WebJobs.Script/Extensions/FileUtility.cs index 88c48285f6..fa9505ebf1 100644 --- a/src/WebJobs.Script/Extensions/FileUtility.cs +++ b/src/WebJobs.Script/Extensions/FileUtility.cs @@ -2,7 +2,9 @@ // 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; using System.Threading.Tasks; @@ -10,11 +12,20 @@ namespace Microsoft.Azure.WebJobs.Script { public static class FileUtility { + private static IFileSystem _default = new FileSystem(); + private static IFileSystem _instance; + + public static IFileSystem Instance + { + get { return _instance ?? _default; } + set { _instance = value; } + } + public static void EnsureDirectoryExists(string path) { - if (!Directory.Exists(path)) + if (!Instance.Directory.Exists(path)) { - Directory.CreateDirectory(path); + Instance.Directory.CreateDirectory(path); } } @@ -22,9 +33,9 @@ public static Task DeleteDirectoryAsync(string path, bool recursive) { return Task.Run(() => { - if (Directory.Exists(path)) + if (Instance.Directory.Exists(path)) { - Directory.Delete(path, recursive); + Instance.Directory.Delete(path, recursive); } }); } @@ -33,9 +44,9 @@ public static Task DeleteIfExistsAsync(string path) { return Task.Run(() => { - if (File.Exists(path)) + if (Instance.File.Exists(path)) { - File.Delete(path); + Instance.File.Delete(path); return true; } return false; @@ -55,7 +66,8 @@ public static async Task WriteAsync(string path, string contents, Encoding encod } encoding = encoding ?? Encoding.UTF8; - using (var writer = new StreamWriter(path, false, encoding, 4096)) + using (Stream fileStream = OpenFile(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) + using (var writer = new StreamWriter(fileStream, encoding, 4096)) { await writer.WriteAsync(contents); } @@ -69,12 +81,18 @@ public static async Task ReadAsync(string path, Encoding encoding = null } encoding = encoding ?? Encoding.UTF8; - using (var reader = new StreamReader(path, encoding, true, 4096)) + using (var fileStream = OpenFile(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(fileStream, encoding, true, 4096)) { return await reader.ReadToEndAsync(); } } + public static Stream OpenFile(string path, FileMode mode, FileAccess access = FileAccess.ReadWrite, FileShare share = FileShare.None) + { + return Instance.File.Open(path, mode, access, share); + } + public static string GetRelativePath(string path1, string path2) { if (path1 == null) @@ -124,20 +142,107 @@ public static Task GetFilesAsync(string path, string prefix) return Task.Run(() => { - return Directory.GetFiles(path, prefix); + return Instance.Directory.GetFiles(path, prefix); }); } public static void CopyDirectory(string sourcePath, string targetPath) { - foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories)) + foreach (string dirPath in Instance.Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories)) + { + Instance.Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath)); + } + + foreach (string filePath in Instance.Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories)) + { + Instance.File.Copy(filePath, filePath.Replace(sourcePath, targetPath), true); + } + } + + public static bool FileExists(string path) => Instance.File.Exists(path); + + public static bool DirectoryExists(string path) => Instance.Directory.Exists(path); + + public static DirectoryInfoBase DirectoryInfoFromDirectoryName(string localSiteRootPath) => Instance.DirectoryInfo.FromDirectoryName(localSiteRootPath); + + public static FileInfoBase FileInfoFromFileName(string localFilePath) => Instance.FileInfo.FromFileName(localFilePath); + + public static string GetFullPath(string path) => Instance.Path.GetFullPath(path); + + private static void DeleteDirectoryContentsSafe(DirectoryInfoBase directoryInfo, bool ignoreErrors) + { + try + { + if (directoryInfo.Exists) + { + foreach (var fsi in directoryInfo.GetFileSystemInfos()) + { + DeleteFileSystemInfo(fsi, ignoreErrors); + } + } + } + catch when (ignoreErrors) { - Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath)); } + } - foreach (string filePath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories)) + private static void DeleteFileSystemInfo(FileSystemInfoBase fileSystemInfo, bool ignoreErrors) + { + if (!fileSystemInfo.Exists) + { + return; + } + + try + { + fileSystemInfo.Attributes = FileAttributes.Normal; + } + catch when (ignoreErrors) + { + } + + if (fileSystemInfo is DirectoryInfoBase directoryInfo) + { + DeleteDirectoryContentsSafe(directoryInfo, ignoreErrors); + } + + DoSafeAction(fileSystemInfo.Delete, ignoreErrors); + } + + public static void DeleteDirectoryContentsSafe(string path, bool ignoreErrors = true) + { + try + { + var directoryInfo = DirectoryInfoFromDirectoryName(path); + if (directoryInfo.Exists) + { + foreach (var fsi in directoryInfo.GetFileSystemInfos()) + { + DeleteFileSystemInfo(fsi, ignoreErrors); + } + } + } + catch when (ignoreErrors) + { + } + } + + public static void DeleteFileSafe(string path) + { + var info = FileInfoFromFileName(path); + DeleteFileSystemInfo(info, ignoreErrors: true); + } + + public static IEnumerable EnumerateDirectories(string path) => Instance.Directory.EnumerateDirectories(path); + + private static void DoSafeAction(Action action, bool ignoreErrors) + { + try + { + action(); + } + catch when (ignoreErrors) { - File.Copy(filePath, filePath.Replace(sourcePath, targetPath), true); } } } diff --git a/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs b/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs index 08fa3edd78..56407d8f1d 100644 --- a/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs +++ b/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs @@ -1,8 +1,11 @@ // 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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Azure.WebJobs.Script.Config; using Microsoft.Extensions.Primitives; @@ -68,5 +71,6 @@ public static bool IsColdStart(this HttpRequest request) { return !string.IsNullOrEmpty(request.GetHeaderValueOrDefault(ScriptConstants.AntaresColdStartHeaderName)); } + public static Uri GetRequestUri(this HttpRequest request) => new Uri(request.GetDisplayUrl()); } } diff --git a/src/WebJobs.Script/Host/ScriptHost.cs b/src/WebJobs.Script/Host/ScriptHost.cs index 02725e59fa..24ad9854fc 100644 --- a/src/WebJobs.Script/Host/ScriptHost.cs +++ b/src/WebJobs.Script/Host/ScriptHost.cs @@ -543,7 +543,7 @@ private Collection LoadFunctionMetadata() Collection functionMetadata; using (_metricsLogger.LatencyEvent(MetricEventNames.HostStartupReadFunctionMetadataLatency)) { - functionMetadata = ReadFunctionMetadata(_directorySnapshot, _startupLogger, FunctionErrors, _settingsManager, ScriptConfig.Functions); + functionMetadata = ReadFunctionsMetadata(_directorySnapshot, _startupLogger, FunctionErrors, _settingsManager, ScriptConfig.Functions); _startupLogger.LogTrace("Function metadata read."); } @@ -1044,7 +1044,7 @@ private static FunctionMetadata ParseFunctionMetadata(string functionName, JObje return functionMetadata; } - public static Collection ReadFunctionMetadata(IEnumerable functionDirectories, ILogger logger, Dictionary> functionErrors, ScriptSettingsManager settingsManager = null, IEnumerable functionWhitelist = null) + public static Collection ReadFunctionsMetadata(IEnumerable functionDirectories, ILogger logger, Dictionary> functionErrors, ScriptSettingsManager settingsManager = null, IEnumerable functionWhitelist = null) { var functions = new Collection(); settingsManager = settingsManager ?? ScriptSettingsManager.Instance; @@ -1056,58 +1056,69 @@ public static Collection ReadFunctionMetadata(IEnumerable> functionErrors, ScriptSettingsManager settingsManager = null, IEnumerable functionWhitelist = null) + { + string functionName = null; + + try + { + // read the function config + // read the function config + string functionConfigPath = Path.Combine(scriptDir, ScriptConstants.FunctionMetadataFileName); + string json = null; try { - // read the function config - string functionConfigPath = Path.Combine(scriptDir, ScriptConstants.FunctionMetadataFileName); - string json = null; - try - { - json = File.ReadAllText(functionConfigPath); - } - catch (FileNotFoundException) - { - // not a function directory - continue; - } + json = File.ReadAllText(functionConfigPath); + } + catch (FileNotFoundException) + { + // not a function directory + return null; + } - functionName = Path.GetFileName(scriptDir); - if (functionWhitelist != null && - !functionWhitelist.Contains(functionName, StringComparer.OrdinalIgnoreCase)) - { - // a functions filter has been specified and the current function is - // not in the filter list - continue; - } + functionName = Path.GetFileName(scriptDir); + if (functionWhitelist != null && + !functionWhitelist.Contains(functionName, StringComparer.OrdinalIgnoreCase)) + { + // a functions filter has been specified and the current function is + // not in the filter list + return null; + } - ValidateName(functionName); + ValidateName(functionName); - JObject functionConfig = JObject.Parse(json); + JObject functionConfig = JObject.Parse(json); - string functionError = null; - FunctionMetadata functionMetadata = null; - if (!TryParseFunctionMetadata(functionName, functionConfig, logger, scriptDir, settingsManager, out functionMetadata, out functionError)) - { - // for functions in error, log the error and don't - // add to the functions collection - AddFunctionError(functionErrors, functionName, functionError); - continue; - } - else if (functionMetadata != null) - { - functions.Add(functionMetadata); - } + string functionError = null; + FunctionMetadata functionMetadata = null; + if (!TryParseFunctionMetadata(functionName, functionConfig, logger, scriptDir, settingsManager, out functionMetadata, out functionError)) + { + // for functions in error, log the error and don't + // add to the functions collection + AddFunctionError(functionErrors, functionName, functionError); + return null; } - catch (Exception ex) + else if (functionMetadata != null) { - // log any unhandled exceptions and continue - AddFunctionError(functionErrors, functionName, Utility.FlattenException(ex, includeSource: false), isFunctionShortName: true); + return functionMetadata; } } - - return functions; + catch (Exception ex) + { + // log any unhandled exceptions and continue + AddFunctionError(functionErrors, functionName, Utility.FlattenException(ex, includeSource: false), isFunctionShortName: true); + } + return null; } internal Collection ReadProxyMetadata(ScriptHostConfiguration config, ScriptSettingsManager settingsManager = null) @@ -1898,4 +1909,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/src/WebJobs.Script/ScriptConstants.cs b/src/WebJobs.Script/ScriptConstants.cs index abbfb021c0..c121214561 100644 --- a/src/WebJobs.Script/ScriptConstants.cs +++ b/src/WebJobs.Script/ScriptConstants.cs @@ -37,7 +37,8 @@ public static class ScriptConstants public const string LoggerHttpRequest = "MS_HttpRequest"; - public const string LogCategoryAdminController = "Host.Controllers.Admin"; + public const string LogCategoryHostController = "Host.Controllers.Host"; + public const string LogCategoryFunctionsController = "Host.Controllers.Functions"; public const string LogCategoryKeysController = "Host.Controllers.Keys"; public const string LogCategoryHostGeneral = "Host.General"; public const string LogCategoryHostMetrics = "Host.Metrics"; diff --git a/src/WebJobs.Script/Utility.cs b/src/WebJobs.Script/Utility.cs index 6272fef6ed..e818cf29cf 100644 --- a/src/WebJobs.Script/Utility.cs +++ b/src/WebJobs.Script/Utility.cs @@ -182,7 +182,7 @@ internal static string GetDefaultHostId(ScriptSettingsManager settingsManager, S .Aggregate(new StringBuilder(), (b, c) => b.Append(c)).ToString(); hostId = $"{sanitizedMachineName}-{Math.Abs(GetStableHash(scriptConfig.RootScriptPath))}"; } - else if (!string.IsNullOrEmpty(settingsManager.AzureWebsiteUniqueSlotName)) + else if (!string.IsNullOrEmpty(settingsManager?.AzureWebsiteUniqueSlotName)) { // If running on Azure Web App, derive the host ID from unique site slot name hostId = settingsManager.AzureWebsiteUniqueSlotName; diff --git a/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs b/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs index 682465e070..22e8a5563d 100644 --- a/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs +++ b/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs @@ -14,6 +14,8 @@ using Microsoft.Azure.WebJobs.Script.Eventing; using Microsoft.Azure.WebJobs.Script.WebHost; using Microsoft.Azure.WebJobs.Script.WebHost.Controllers; +using Microsoft.Azure.WebJobs.Script.WebHost.Filters; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; using Microsoft.Azure.WebJobs.Script.WebHost.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -30,7 +32,7 @@ public class AdminControllerTests : IDisposable private Mock hostMock; private Mock managerMock; private Collection testFunctions; - private AdminController testController; + private FunctionsController testController; private Mock secretsManagerMock; public AdminControllerTests() @@ -42,6 +44,7 @@ public AdminControllerTests() var environment = new NullScriptHostEnvironment(); var eventManager = new Mock(); var mockRouter = new Mock(); + var mockWebFunctionManager = new Mock(); hostMock = new Mock(MockBehavior.Strict, new object[] { environment, eventManager.Object, config, null, null, null }); hostMock.Setup(p => p.Functions).Returns(testFunctions); @@ -51,7 +54,7 @@ public AdminControllerTests() managerMock = new Mock(MockBehavior.Strict, new object[] { config, new TestSecretManagerFactory(secretsManagerMock.Object), eventManager.Object, _settingsManager, settings, mockRouter.Object, NullLoggerFactory.Instance }); managerMock.SetupGet(p => p.Instance).Returns(hostMock.Object); - testController = new AdminController(managerMock.Object, settings, new LoggerFactory(), null); + testController = new FunctionsController(mockWebFunctionManager.Object, managerMock.Object, new LoggerFactory()); } [Fact] diff --git a/test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs b/test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs new file mode 100644 index 0000000000..818f99e8b9 --- /dev/null +++ b/test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Azure.WebJobs.Script.WebHost; +using Microsoft.Azure.WebJobs.Script.WebHost.Helpers; +using Microsoft.Azure.WebJobs.Script.WebHost.Management; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Managment +{ + public class VirtualFileSystemFacts : IDisposable + { + private const string SiteRootPath = @"C:\DWASFiles\Sites\SiteName\VirtualDirectory0"; + private static readonly string LocalSiteRootPath = Path.GetFullPath(Path.Combine(SiteRootPath, @"..")); + + [Fact] + public async Task DeleteRequestReturnsNotFoundIfItemDoesNotExist() + { + // Arrange + string path = @"/foo/bar/"; + var fileInfo = new Mock(); + fileInfo.SetupGet(f => f.Attributes).Returns((FileAttributes)(-1)); + var dirInfo = new Mock(); + dirInfo.SetupGet(d => d.Attributes).Returns((FileAttributes)(-1)); + var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object); + var controller = CreateVirtualFileSystem(); + FileUtility.Instance = fileSystem; + + // Act + var result = await controller.DeleteItem(CreateRequest(path)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); + } + + [Fact] + public async Task DeleteRequestDoesNotRecursivelyDeleteDirectoriesByDefault() + { + // Arrange + string path = @"/foo/bar/"; + var fileInfo = new Mock(); + fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Directory); + var dirInfo = new Mock(); + dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Directory); + var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object); + FileUtility.Instance = fileSystem; + + var controller = CreateVirtualFileSystem(); + + // Act + await controller.DeleteItem(CreateRequest(path)); + + // Assert + dirInfo.Verify(d => d.Delete(false), Times.Once()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DeleteRequestInvokesRecursiveDeleteBasedOnParameter(bool recursive) + { + // Arrange + string path = @"/foo/bar/"; + var fileInfo = new Mock(); + fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Directory); + var dirInfo = new Mock(); + dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Directory); + var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object); + var controller = CreateVirtualFileSystem(); + FileUtility.Instance = fileSystem; + + // Act + var response = await controller.DeleteItem(CreateRequest(path), recursive); + + // Assert + dirInfo.Verify(d => d.Delete(recursive), Times.Once()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DeleteItemReturnsPreconditionFailedResponseIfFileDeleteDoesNotContainETag() + { + // Arrange + string path = @"/foo/bar.txt"; + var fileInfo = new Mock(); + fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Normal); + var dirInfo = new Mock(); + dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Normal); + var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object); + FileUtility.Instance = fileSystem; + + var controller = CreateVirtualFileSystem(); + + // Act + var response = await controller.DeleteItem(CreateRequest(path)); + + // Assert + Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode); + } + + [Fact] + public async Task DeleteItemReturnsPreconditionFailedIfETagDoesNotMatch() + { + // Arrange + var date = new DateTime(2012, 07, 06); + string path = @"/foo/bar.txt"; + var fileInfo = new Mock(); + fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Normal); + fileInfo.SetupGet(f => f.LastWriteTimeUtc).Returns(date); + var dirInfo = new Mock(); + dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Normal); + var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object); + FileUtility.Instance = fileSystem; + + var controller = CreateVirtualFileSystem(); + var request = CreateRequest(path); + request.Headers.TryAdd("If-Match", "will-not-match"); + + // Act + var response = await controller.DeleteItem(request); + + // Assert + Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode); + } + + public static IEnumerable DeleteItemDeletesFileIfETagMatchesData + { + get + { + yield return new object[] { EntityTagHeaderValue.Any }; + yield return new object[] { new EntityTagHeaderValue("\"00c0b16b2129cf08\"") }; + } + } + + [Theory] + [MemberData("DeleteItemDeletesFileIfETagMatchesData")] + public async Task DeleteItemDeletesFileIfETagMatches(EntityTagHeaderValue etag) + { + // Arrange + var date = new DateTime(2012, 07, 06); + string path = @"/foo/bar.txt"; + var fileInfo = new Mock(); + fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Normal); + fileInfo.SetupGet(f => f.LastWriteTimeUtc).Returns(date); + var dirInfo = new Mock(); + dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Normal); + var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object); + FileUtility.Instance = fileSystem; + + var controller = CreateVirtualFileSystem(); + var request = CreateRequest(path); + request.Headers.Add("If-Match", etag.Tag); + + // Act + var response = await controller.DeleteItem(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + fileInfo.Verify(f => f.Delete()); + } + + public static IEnumerable MapRouteToLocalPathData + { + get + { + yield return new object[] { "https://localhost/vfs", SiteRootPath }; + yield return new object[] { "https://localhost/vfs/LogFiles/kudu", SiteRootPath + @"\LogFiles\kudu" }; + + yield return new object[] { "https://localhost/vfs/SystemDrive", "%SystemDrive%" }; + yield return new object[] { "https://localhost/vfs/SystemDrive/windows", @"%SystemDrive%\windows" }; + yield return new object[] { "https://localhost/vfs/SystemDrive/Program Files (x86)", @"%ProgramFiles(x86)%" }; + + yield return new object[] { "https://localhost/vfs/LocalSiteRoot", LocalSiteRootPath }; + yield return new object[] { "https://localhost/vfs/LocalSiteRoot/Temp", LocalSiteRootPath + @"\Temp" }; + } + } + + private static HttpRequest CreateRequest(string path) + { + var r = new DefaultHttpContext().Request; + r.Host = new HostString("localhost"); + r.Path = new PathString("/admin/vfs" + path); + return r; + } + + private static IFileSystem CreateFileSystem(string path, DirectoryInfoBase dir, FileInfoBase file) + { + var directoryFactory = new Mock(); + directoryFactory.Setup(d => d.FromDirectoryName(It.IsAny())) + .Returns(dir); + var fileInfoFactory = new Mock(); + fileInfoFactory.Setup(f => f.FromFileName(It.IsAny())) + .Returns(file); + + var pathBase = new Mock(); + pathBase.Setup(p => p.GetFullPath(It.IsAny())) + .Returns(s => s); + + var fileSystem = new Mock(); + fileSystem.SetupGet(f => f.DirectoryInfo).Returns(directoryFactory.Object); + fileSystem.SetupGet(f => f.FileInfo).Returns(fileInfoFactory.Object); + fileSystem.SetupGet(f => f.Path).Returns(pathBase.Object); + + FileUtility.Instance = fileSystem.Object; + + return fileSystem.Object; + } + + private VirtualFileSystem CreateVirtualFileSystem() + { + return new VirtualFileSystem(new WebHostSettings + { + ScriptPath = SiteRootPath + }); + } + + public void Dispose() + { + // clear FileUtility.Instance after this test is done + FileUtility.Instance = null; + } + } +} diff --git a/test/WebJobs.Script.Tests/ScriptHostTests.cs b/test/WebJobs.Script.Tests/ScriptHostTests.cs index 3fb1260eb9..bb949ed374 100644 --- a/test/WebJobs.Script.Tests/ScriptHostTests.cs +++ b/test/WebJobs.Script.Tests/ScriptHostTests.cs @@ -68,7 +68,7 @@ public void ReadFunctionMetadata_Succeeds() var functionErrors = new Dictionary>(); var functionDirectories = Directory.EnumerateDirectories(config.RootScriptPath); - var metadata = ScriptHost.ReadFunctionMetadata(functionDirectories, null, functionErrors); + var metadata = ScriptHost.ReadFunctionsMetadata(functionDirectories, null, functionErrors); Assert.Equal(40, metadata.Count); } @@ -841,7 +841,7 @@ public void ApplyHostHealthMonitorConfig_AppliesExpectedSettings() { 'healthMonitor': { 'enabled': false - } + } }"); scriptConfig = new ScriptHostConfiguration(); ScriptHost.ApplyConfiguration(config, scriptConfig); @@ -857,7 +857,7 @@ public void ApplyHostHealthMonitorConfig_AppliesExpectedSettings() 'healthCheckThreshold': 77, 'counterThreshold': 0.77 } - + }"); scriptConfig = new ScriptHostConfiguration(); ScriptHost.ApplyConfiguration(config, scriptConfig); @@ -1569,4 +1569,4 @@ public TestFixture() public ScriptHost Host { get; private set; } } } -} +} \ No newline at end of file diff --git a/test/WebJobs.Script.Tests/Security/SecretManagerTests.cs b/test/WebJobs.Script.Tests/Security/SecretManagerTests.cs index 3ba0481edc..8a3ba6a356 100644 --- a/test/WebJobs.Script.Tests/Security/SecretManagerTests.cs +++ b/test/WebJobs.Script.Tests/Security/SecretManagerTests.cs @@ -614,7 +614,7 @@ await Task.WhenAll( Task.Run(async () => { // Lock the file - using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write)) + using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write, FileShare.None)) { await Task.Delay(500); } @@ -636,7 +636,7 @@ await Task.WhenAll( Task.Run(async () => { // Lock the file - using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write)) + using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write, FileShare.None)) { await Task.Delay(3000); } @@ -683,7 +683,7 @@ await Task.WhenAll( Task.Run(async () => { // Lock the file - using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write)) + using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write, FileShare.None)) { await Task.Delay(500); } @@ -705,7 +705,7 @@ await Task.WhenAll( Task.Run(async () => { // Lock the file - using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write)) + using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Write, FileShare.None)) { await Task.Delay(3000); }