Skip to content

Commit 21b2126

Browse files
committed
Implement sync triggers in the runtime
1 parent 8ba5ba0 commit 21b2126

File tree

12 files changed

+448
-17
lines changed

12 files changed

+448
-17
lines changed

src/WebJobs.Script.WebHost/Controllers/HostController.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.Azure.WebJobs.Host;
1515
using Microsoft.Azure.WebJobs.Script.WebHost.Authentication;
1616
using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
17+
using Microsoft.Azure.WebJobs.Script.WebHost.Management;
1718
using Microsoft.Azure.WebJobs.Script.WebHost.Models;
1819
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies;
1920
using Microsoft.Extensions.Logging;
@@ -31,13 +32,15 @@ public class HostController : Controller
3132
private readonly WebHostSettings _webHostSettings;
3233
private readonly ILogger _logger;
3334
private readonly IAuthorizationService _authorizationService;
35+
private readonly IWebFunctionsManager _functionsManager;
3436

35-
public HostController(WebScriptHostManager scriptHostManager, WebHostSettings webHostSettings, ILoggerFactory loggerFactory, IAuthorizationService authorizationService)
37+
public HostController(WebScriptHostManager scriptHostManager, WebHostSettings webHostSettings, ILoggerFactory loggerFactory, IAuthorizationService authorizationService, IWebFunctionsManager functionsManager)
3638
{
3739
_scriptHostManager = scriptHostManager;
3840
_webHostSettings = webHostSettings;
3941
_logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryHostController);
4042
_authorizationService = authorizationService;
43+
_functionsManager = functionsManager;
4144
}
4245

4346
[HttpGet]
@@ -125,6 +128,19 @@ public IActionResult LaunchDebugger()
125128
return StatusCode(StatusCodes.Status501NotImplemented);
126129
}
127130

131+
[HttpPost]
132+
[Route("admin/host/synctriggers")]
133+
[Authorize(Policy = PolicyNames.AdminAuthLevel)]
134+
public async Task<IActionResult> SyncTriggers()
135+
{
136+
(var success, var error) = await _functionsManager.TrySyncTriggers();
137+
138+
// Return a dummy body to make it valid in ARM template action evaluation
139+
return success
140+
? Ok(new { status = "success" })
141+
: StatusCode(StatusCodes.Status500InternalServerError, new { status = error });
142+
}
143+
128144
[HttpGet]
129145
[HttpPost]
130146
[Authorize(AuthenticationSchemes = AuthLevelAuthenticationDefaults.AuthenticationScheme)]

src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@
33

44
using System;
55
using System.IO;
6-
using System.Runtime.InteropServices;
76
using System.Threading.Tasks;
87
using Microsoft.AspNetCore.Http;
9-
using Microsoft.Azure.WebJobs.Script.Config;
108
using Microsoft.Azure.WebJobs.Script.Description;
119
using Microsoft.Azure.WebJobs.Script.Management.Models;
12-
using Microsoft.Azure.WebJobs.Script.WebHost.Helpers;
1310
using Microsoft.Azure.WebJobs.Script.WebHost.Management;
1411
using Newtonsoft.Json.Linq;
1512

@@ -43,7 +40,7 @@ public static async Task<FunctionMetadataResponse> ToFunctionMetadataResponse(th
4340
TestDataHref = VirtualFileSystem.FilePathToVfsUri(functionMetadata.GetTestDataFilePath(config), baseUrl, config),
4441
Href = GetFunctionHref(functionMetadata.Name, baseUrl),
4542
TestData = await GetTestData(functionMetadata.GetTestDataFilePath(config), config),
46-
Config = await GetFunctionConfig(functionMetadataFilePath, config),
43+
Config = await GetFunctionConfig(functionMetadataFilePath),
4744

4845
// Properties below this comment are not present in the kudu version.
4946
IsDirect = functionMetadata.IsDirect,
@@ -53,13 +50,50 @@ public static async Task<FunctionMetadataResponse> ToFunctionMetadataResponse(th
5350
return response;
5451
}
5552

53+
/// <summary>
54+
/// This method converts a FunctionMetadata into a JObject
55+
/// the scale conteller understands. It's mainly the trigger binding
56+
/// with functionName inserted in it.
57+
/// </summary>
58+
/// <param name="functionMetadata">FunctionMetadata object to convert to a JObject.</param>
59+
/// <param name="config">ScriptHostConfiguration to read RootScriptPath from.</param>
60+
/// <returns>JObject that represent the trigger for scale controller to consume</returns>
61+
public static async Task<JObject> ToFunctionTrigger(this FunctionMetadata functionMetadata, ScriptHostConfiguration config)
62+
{
63+
// Only look at the function if it's not disabled
64+
if (!functionMetadata.IsDisabled)
65+
{
66+
// Get function.json path
67+
var functionPath = Path.Combine(config.RootScriptPath, functionMetadata.Name);
68+
var functionMetadataFilePath = Path.Combine(functionPath, ScriptConstants.FunctionMetadataFileName);
69+
70+
// Read function.json as a JObject
71+
var functionConfig = await GetFunctionConfig(functionMetadataFilePath);
72+
73+
// Find the trigger and add functionName to it
74+
// Q: Do we plan on supporting multiple triggers?
75+
foreach (JObject binding in (JArray)functionConfig["bindings"])
76+
{
77+
var type = (string)binding["type"];
78+
if (type.EndsWith("Trigger", StringComparison.OrdinalIgnoreCase))
79+
{
80+
binding.Add("functionName", functionMetadata.Name);
81+
return binding;
82+
}
83+
}
84+
}
85+
86+
// If the function is disabled or has no trigger return null
87+
return null;
88+
}
89+
5690
public static string GetTestDataFilePath(this FunctionMetadata functionMetadata, ScriptHostConfiguration config) =>
5791
GetTestDataFilePath(functionMetadata.Name, config);
5892

5993
public static string GetTestDataFilePath(string functionName, ScriptHostConfiguration config) =>
6094
Path.Combine(config.TestDataPath, $"{functionName}.dat");
6195

62-
private static async Task<JObject> GetFunctionConfig(string path, ScriptHostConfiguration config)
96+
private static async Task<JObject> GetFunctionConfig(string path)
6397
{
6498
try
6599
{
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Security.Cryptography;
9+
using System.Text;
10+
11+
namespace Microsoft.Azure.WebJobs.Script.WebHost.Helpers
12+
{
13+
public static class SimpleWebTokenHelper
14+
{
15+
/// <summary>
16+
/// A SWT or a Simple Web Token is a token that's made of key=value pairs seperated
17+
/// by &. We only specify expiration in ticks from now (exp={ticks})
18+
/// The SWT is then returned as an encrypted string
19+
/// </summary>
20+
/// <param name="validUntil">Datetime for when the token should expire</param>
21+
/// <returns>a SWT signed by this app</returns>
22+
public static string CreateToken(DateTime validUntil) => Encrypt($"exp={validUntil.Ticks}");
23+
24+
private static string Encrypt(string value)
25+
{
26+
using (var aes = new AesManaged { Key = GetWebSiteAuthEncryptionKey() })
27+
{
28+
// IV is always generated for the key every time
29+
aes.GenerateIV();
30+
var input = Encoding.UTF8.GetBytes(value);
31+
var iv = Convert.ToBase64String(aes.IV);
32+
33+
using (var encrypter = aes.CreateEncryptor(aes.Key, aes.IV))
34+
using (var cipherStream = new MemoryStream())
35+
{
36+
using (var cryptoStream = new CryptoStream(cipherStream, encrypter, CryptoStreamMode.Write))
37+
using (var binaryWriter = new BinaryWriter(cryptoStream))
38+
{
39+
binaryWriter.Write(input);
40+
cryptoStream.FlushFinalBlock();
41+
}
42+
43+
// return {iv}.{swt}.{sha236(key)}
44+
return string.Format("{0}.{1}.{2}", iv, Convert.ToBase64String(cipherStream.ToArray()), GetSHA256Base64String(aes.Key));
45+
}
46+
}
47+
}
48+
49+
private static string GetSHA256Base64String(byte[] key)
50+
{
51+
using (var sha256 = new SHA256Managed())
52+
{
53+
return Convert.ToBase64String(sha256.ComputeHash(key));
54+
}
55+
}
56+
57+
private static byte[] GetWebSiteAuthEncryptionKey()
58+
{
59+
var hexOrBase64 = Environment.GetEnvironmentVariable("WEBSITE_AUTH_ENCRYPTION_KEY");
60+
if (string.IsNullOrEmpty(hexOrBase64))
61+
{
62+
throw new InvalidOperationException("No WEBSITE_AUTH_ENCRYPTION_KEY defined in the environment");
63+
}
64+
65+
// only support 32 bytes (256 bits) key length
66+
if (hexOrBase64.Length == 64)
67+
{
68+
return Enumerable.Range(0, hexOrBase64.Length)
69+
.Where(x => x % 2 == 0)
70+
.Select(x => Convert.ToByte(hexOrBase64.Substring(x, 2), 16))
71+
.ToArray();
72+
}
73+
74+
return Convert.FromBase64String(hexOrBase64);
75+
}
76+
}
77+
}

src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ public interface IWebFunctionsManager
1717
Task<(bool, bool, FunctionMetadataResponse)> CreateOrUpdate(string name, FunctionMetadataResponse functionMetadata, HttpRequest request);
1818

1919
(bool, string) TryDeleteFunction(FunctionMetadataResponse function);
20+
21+
Task<(bool success, string error)> TrySyncTriggers();
2022
}
2123
}

src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66
using System.Collections.ObjectModel;
77
using System.IO;
88
using System.Linq;
9+
using System.Net.Http;
10+
using System.Text;
911
using System.Threading.Tasks;
1012
using Microsoft.AspNetCore.Http;
11-
using Microsoft.Azure.WebJobs.Script;
13+
using Microsoft.Azure.WebJobs.Script.Description;
14+
using Microsoft.Azure.WebJobs.Script.Extensions;
1215
using Microsoft.Azure.WebJobs.Script.Management.Models;
13-
using Microsoft.Azure.WebJobs.Script.WebHost;
1416
using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
1517
using Microsoft.Azure.WebJobs.Script.WebHost.Helpers;
1618
using Microsoft.Extensions.Logging;
1719
using Newtonsoft.Json;
20+
using Newtonsoft.Json.Linq;
1821

1922
namespace Microsoft.Azure.WebJobs.Script.WebHost.Management
2023
{
@@ -37,9 +40,7 @@ public WebFunctionsManager(WebHostSettings webSettings, ILoggerFactory loggerFac
3740
/// <returns>collection of FunctionMetadataResponse</returns>
3841
public async Task<IEnumerable<FunctionMetadataResponse>> GetFunctionsMetadata(HttpRequest request)
3942
{
40-
return await ScriptHost.ReadFunctionsMetadata(FileUtility.EnumerateDirectories(_config.RootScriptPath), _logger, new Dictionary<string, Collection<string>>())
41-
.Select(fm => fm.ToFunctionMetadataResponse(request, _config))
42-
.WhenAll();
43+
return await GetFunctionsMetadata().Select(fm => fm.ToFunctionMetadataResponse(request, _config)).WhenAll();
4344
}
4445

4546
/// <summary>
@@ -123,7 +124,7 @@ await functionMetadata
123124
/// <returns>(success, FunctionMetadataResponse)</returns>
124125
public async Task<(bool, FunctionMetadataResponse)> TryGetFunction(string name, HttpRequest request)
125126
{
126-
var functionMetadata = ScriptHost.ReadFunctionMetadata(Path.Combine(_config.RootScriptPath, name), _logger, new Dictionary<string, Collection<string>>());
127+
var functionMetadata = ScriptHost.ReadFunctionMetadata(Path.Combine(_config.RootScriptPath, name), new Dictionary<string, Collection<string>>());
127128
if (functionMetadata != null)
128129
{
129130
return (true, await functionMetadata.ToFunctionMetadataResponse(request, _config));
@@ -158,6 +159,88 @@ await functionMetadata
158159
}
159160
}
160161

162+
/// <summary>
163+
/// Try to perform sync triggers to the scale controller
164+
/// </summary>
165+
/// <returns>(success, error)</returns>
166+
public async Task<(bool success, string error)> TrySyncTriggers()
167+
{
168+
var durableTaskHubName = await GetDurableTaskHubName();
169+
var functionsTriggers = (await GetFunctionsMetadata()
170+
.Select(f => f.ToFunctionTrigger(_config))
171+
.WhenAll())
172+
.Where(t => t != null)
173+
.Select(t =>
174+
{
175+
// if we have a durableTask hub name and the function trigger is either orchestrationTrigger OR activityTrigger,
176+
// add a property "taskHubName" with durable task hub name.
177+
if (durableTaskHubName != null
178+
&& (t["type"]?.ToString().Equals("orchestrationTrigger", StringComparison.OrdinalIgnoreCase) == true
179+
|| t["type"]?.ToString().Equals("activityTrigger", StringComparison.OrdinalIgnoreCase) == true))
180+
{
181+
t["taskHubName"] = durableTaskHubName;
182+
}
183+
return t;
184+
});
185+
186+
if (FileUtility.FileExists(Path.Combine(_config.RootScriptPath, ScriptConstants.ProxyMetadataFileName)))
187+
{
188+
// This is because we still need to scale function apps that are proxies only
189+
functionsTriggers = functionsTriggers.Append(new JObject(new { type = "routingTrigger" }));
190+
}
191+
192+
return await InternalSyncTriggers(functionsTriggers);
193+
}
194+
195+
// This function will call POST https://{app}.azurewebsites.net/operation/settriggers with the content
196+
// of triggers. It'll verify app owner ship using a SWT token valid for 5 minutes. It should be plenty.
197+
private async Task<(bool, string)> InternalSyncTriggers(IEnumerable<JObject> triggers)
198+
{
199+
var content = JsonConvert.SerializeObject(triggers);
200+
var token = SimpleWebTokenHelper.CreateToken(DateTime.UtcNow.AddMinutes(5));
201+
202+
// This will be a problem for national clouds. However, Antares isn't injecting the
203+
// WEBSITE_HOSTNAME yet for linux apps. So until then will use this.
204+
var url = $"https://{Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")}.azurewebsites.net/operations/settriggers";
205+
206+
using (var request = new HttpRequestMessage(HttpMethod.Post, url))
207+
{
208+
// This has to start with Mozilla because the frontEnd checks for it.
209+
request.Headers.Add("User-Agent", "Mozilla/5.0");
210+
request.Headers.Add("x-ms-site-restricted-token", token);
211+
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
212+
213+
var response = await HttpClientUtility.Instance.SendAsync(request);
214+
return response.IsSuccessStatusCode
215+
? (true, string.Empty)
216+
: (false, $"Sync triggers failed with: {response.StatusCode}");
217+
}
218+
}
219+
220+
private IEnumerable<FunctionMetadata> GetFunctionsMetadata()
221+
{
222+
return ScriptHost
223+
.ReadFunctionsMetadata(FileUtility.EnumerateDirectories(_config.RootScriptPath), _logger, new Dictionary<string, Collection<string>>());
224+
}
225+
226+
private async Task<string> GetDurableTaskHubName()
227+
{
228+
string hostJsonPath = Path.Combine(_config.RootScriptPath, ScriptConstants.HostMetadataFileName);
229+
if (FileUtility.FileExists(hostJsonPath))
230+
{
231+
// We're looking for {VALUE}
232+
// {
233+
// "durableTask": {
234+
// "HubName": "{VALUE}"
235+
// }
236+
// }
237+
var hostJson = JsonConvert.DeserializeObject<HostJsonModel>(await FileUtility.ReadAsync(hostJsonPath));
238+
return hostJson?.DurableTask?.HubName;
239+
}
240+
241+
return null;
242+
}
243+
161244
private void DeleteFunctionArtifacts(FunctionMetadataResponse function)
162245
{
163246
// TODO: clear secrets
@@ -169,5 +252,15 @@ private void DeleteFunctionArtifacts(FunctionMetadataResponse function)
169252
FileUtility.DeleteFileSafe(testDataPath);
170253
}
171254
}
255+
256+
private class HostJsonModel
257+
{
258+
public DurableTaskHostModel DurableTask { get; set; }
259+
}
260+
261+
private class DurableTaskHostModel
262+
{
263+
public string HubName { get; set; }
264+
}
172265
}
173266
}

src/WebJobs.Script/Extensions/FileUtility.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ public static async Task<string> ReadAsync(string path, Encoding encoding = null
8888
}
8989
}
9090

91+
public static string ReadAllText(string path) => Instance.File.ReadAllText(path);
92+
9193
public static Stream OpenFile(string path, FileMode mode, FileAccess access = FileAccess.ReadWrite, FileShare share = FileShare.None)
9294
{
9395
return Instance.File.Open(path, mode, access, share);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System.Net.Http;
5+
6+
namespace Microsoft.Azure.WebJobs.Script
7+
{
8+
/// <summary>
9+
/// This class holds a static instance for HttpClient for all to use.
10+
/// It also allows injecting in a new HttpClient for unit testing
11+
/// </summary>
12+
public static class HttpClientUtility
13+
{
14+
private static HttpClient _default = new HttpClient();
15+
private static HttpClient _instance;
16+
17+
public static HttpClient Instance
18+
{
19+
get { return _instance ?? _default; }
20+
// Used for testing. You can update this with an HttpClient with a custom
21+
// HttpClientHandler with a custom SendAsync implementation.
22+
internal set { _instance = value; }
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)