Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ private async Task ApplyStartContextIfPresent(CancellationToken cancellationToke
{
_logger.LogDebug("Applying host context");

var encryptedAssignmentContext = JsonConvert.DeserializeObject<EncryptedHostAssignmentContext>(startContext);
var assignmentContext = _startupContextProvider.SetContext(encryptedAssignmentContext);
var hostAssignmentRequest = JsonConvert.DeserializeObject<HostAssignmentRequest>(startContext);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above TryGetStartContextOrNullAsync virtual call has a different implementation on Legion and Atlas. This same hosted service code is running on each SKU. Just want to confirm that there isn't any breaking format change here - the persisted assignment context is always encrypted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried for legion changes by giving encrypted and unencrypted payload for controller and it is working fine. Means encrypted path of controller which is existing is working fine.

Any way to test this storage path?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just pointing out how the system behaves. Likely things are OK, since up to this point only encrypted was supported, and your new deserialization supports both old and new formats. I'm just this out so you can think about this and ensure no breaks. Bala knows the context storage logic the best so you might have him sign off on this as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Will check with Bala

Copy link
Contributor Author

@manikantanallagatla manikantanallagatla Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage one is only used in CV1 on ACI path(Atlas). We are not changing anything wrt encryption payload. So, storage path should not be affected by this repo. They should continue the encrypted payload and no changes needed.

For legion path, technically it should work as we are storing the volume in https://msazure.visualstudio.com/One/_git/AAPT-Antares-Functions-Docker?path=/images/flexconsumption/components/appserver/cv2/pkg/instance/instance.go&version=GBdev&line=76&lineEnd=77&lineStartColumn=1&lineEndColumn=1&lineStyle=plain&_a=contents

I will check legion for both encrypted and unencrypted path

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have tested both paths and they are working fine.

var assignmentContext = _startupContextProvider.SetContext(hostAssignmentRequest);
await SpecializeMSISideCar(assignmentContext);

try
Expand Down
25 changes: 21 additions & 4 deletions src/WebJobs.Script.WebHost/Controllers/InstanceController.cs
Original file line number Diff line number Diff line change
@@ -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.Threading.Tasks;
Expand Down Expand Up @@ -35,11 +35,28 @@ public InstanceController(IEnvironment environment, IInstanceManager instanceMan
[HttpPost]
[Route("admin/instance/assign")]
[Authorize(Policy = PolicyNames.AdminAuthLevel)]
public async Task<IActionResult> Assign([FromBody] EncryptedHostAssignmentContext encryptedAssignmentContext)
public async Task<IActionResult> Assign([FromBody] HostAssignmentRequest hostAssignmentContext)
{
_logger.LogDebug($"Starting container assignment for host : {Request?.Host}. ContextLength is: {encryptedAssignmentContext.EncryptedContext?.Length}");
if (string.IsNullOrEmpty(hostAssignmentContext.EncryptedContext) &&
hostAssignmentContext.AssignmentContext == null)
{
return BadRequest("Atleast one of Assignment context and EncryptedContext needs to be set.");
}
if (!string.IsNullOrEmpty(hostAssignmentContext.EncryptedContext) &&
!(hostAssignmentContext.AssignmentContext == null))
{
return BadRequest("Only one of Assignment context and EncryptedContext needs to be set.");
}
if (!string.IsNullOrEmpty(hostAssignmentContext.EncryptedContext))
{
_logger.LogDebug($"Starting container assignment for host : {Request?.Host}. ContextLength is {hostAssignmentContext.EncryptedContext.Length}");
}
else
{
_logger.LogDebug($"Starting container assignment for host : {Request?.Host}. Using unencrypted assignment context");
}

var assignmentContext = _startupContextProvider.SetContext(encryptedAssignmentContext);
var assignmentContext = _startupContextProvider.SetContext(hostAssignmentContext);

// before starting the assignment we want to perform as much
// up front validation on the context as possible
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@

namespace Microsoft.Azure.WebJobs.Script.WebHost.Models
{
public class EncryptedHostAssignmentContext
public class HostAssignmentRequest
{
[JsonProperty("encryptedContext")]
[JsonProperty("encryptedContext", DefaultValueHandling = DefaultValueHandling.Ignore)]
public string EncryptedContext { get; set; }

[JsonProperty("assignmentContext", DefaultValueHandling = DefaultValueHandling.Ignore)]
public HostAssignmentContext AssignmentContext { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ public static TokenValidationParameters CreateTokenValidationParameters()
result.ValidIssuers =
[
AppServiceCoreUri,
LegionCoreUri, // ATOL: Specialization is invoked from Legion.
string.Format(ScmSiteUriFormat, ScriptSettingsManager.Instance.GetSetting(AzureWebsiteName)),
string.Format(SiteUriFormat, ScriptSettingsManager.Instance.GetSetting(AzureWebsiteName))
];
Expand Down
15 changes: 10 additions & 5 deletions src/WebJobs.Script.WebHost/StartupContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,17 @@ private StartupContext GetStartupContextOrNull()
/// Decrypt and deserialize the specified context, and apply values from it to the
/// startup cache context.
/// </summary>
/// <param name="encryptedContext">The encrypted assignment context.</param>
/// <returns>The decrypted assignment context</returns>
public virtual HostAssignmentContext SetContext(EncryptedHostAssignmentContext encryptedContext)
/// <param name="hostAssignmentRequest">The Host assignment request.</param>
/// <returns>The decrypted assignment context.</returns>
public virtual HostAssignmentContext SetContext(HostAssignmentRequest hostAssignmentRequest)
{
string decryptedContext = EncryptionHelper.Decrypt(encryptedContext.EncryptedContext, environment: _environment);
var hostAssignmentContext = JsonConvert.DeserializeObject<HostAssignmentContext>(decryptedContext);
var hostAssignmentContext = hostAssignmentRequest.AssignmentContext;

if (!string.IsNullOrEmpty(hostAssignmentRequest.EncryptedContext))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if you shouldn't add a clause hostAssignmentContext == null to this if, making it clear that only one of these should ever be specified. As opposed to the precedence handling you have here, where both can be specified, but encrypted wins.

I realize in the controller method you do validation, but there's another path where this method is called, where the request is pulled from storage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how the storage path works. But shall we move the handling that only one needs to be specified here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are sure storage account path only takes encrypted payload, we can have method overloading for SetContext method. One which takes encrypted payload and other that takes unencrypted payload

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage path where context is pulled from storage MUST be encrypted since data must be encrypted at rest.

Copy link
Contributor

@jviau jviau Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage path where context is pulled from storage MUST be encrypted since data must be encrypted at rest.

This is a bit besides the point, but is this the customer's storage account? The encrypted at rest requirement here would be the storage account itself satisfying that. All data in a storage account is already "encrypted at rest": https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption.

The only time we, the Functions Host team, would have an "encrypted at rest" requirement is if we were offering some storage solution which did not already have encrypted-at-rest features.

But back to the main point: I agree that if we expect storage scenario to always have client-side encryption (which is what this is), then we should assert that as necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage is only used in CV1 on ACI path. We are not touching that path. So, storage wise, no change with this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think no changes are needed for storage and current validations in StartupContextProvider.cs seems fine

{
string decryptedContext = EncryptionHelper.Decrypt(hostAssignmentRequest.EncryptedContext, environment: _environment);
hostAssignmentContext = JsonConvert.DeserializeObject<HostAssignmentContext>(decryptedContext);
}

// Don't update StartupContext for warmup requests
if (!hostAssignmentContext.IsWarmupRequest)
Expand Down
1 change: 1 addition & 0 deletions src/WebJobs.Script/ScriptConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ public static class ScriptConstants
public const string ScmSiteUriFormat = "https://{0}.scm.azurewebsites.net";
public const string SiteUriFormat = "https://{0}.azurewebsites.net";
public const string AppServiceCoreUri = "https://appservice.core.azurewebsites.net";
public const string LegionCoreUri = "https://legion.core.azurewebsites.net";

public const string AzureFunctionsSystemDirectoryName = ".azurefunctions";
public const string HttpMethodConstraintName = "httpMethod";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ public async Task StandbyModeE2E_LinuxConsumptionOnLegion()
Assert.Equal(typeof(LinuxContainerLegionMetricsPublisher), webHost.Services.GetRequiredService<IMetricsPublisher>().GetType());
}

[Fact]
public async Task StandbyModeE2E_LinuxContainer()
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task StandbyModeE2E_LinuxContainer(bool useEncyptedPayload)
{
byte[] bytes = TestHelpers.GenerateKeyBytes();
var encryptionKey = Convert.ToBase64String(bytes);
Expand Down Expand Up @@ -114,7 +116,7 @@ public async Task StandbyModeE2E_LinuxContainer()
await VerifyWarmupSucceeds(restart: true);

// now specialize the site
await Assign(encryptionKey);
await Assign(encryptionKey, useEncyptedPayload);

// immediately call a function - expect the call to block until
// the host is fully specialized
Expand Down Expand Up @@ -240,7 +242,7 @@ public async Task LinuxContainer_TimeZoneEnvVariableE2E()

}

private async Task Assign(string encryptionKey)
private async Task Assign(string encryptionKey, bool useEncryptedPayload)
{
// create a zip package
var testFunctionPath = Path.Combine("TestScripts", "Node", "HttpTrigger");
Expand All @@ -260,32 +262,36 @@ private async Task Assign(string encryptionKey)
string uri = "admin/instance/assign";
var request = new HttpRequestMessage(HttpMethod.Post, uri);
var environment = new Dictionary<string, string>()
{
{ EnvironmentSettingNames.AzureWebsiteZipDeployment, sasUri.ToString() },
{ RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName, "~2" },
{ RpcWorkerConstants.FunctionWorkerRuntimeSettingName, "node" }
};
{
{ EnvironmentSettingNames.AzureWebsiteZipDeployment, sasUri.ToString() },
{ RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName, "~2" },
{ RpcWorkerConstants.FunctionWorkerRuntimeSettingName, "node" }
};
var assignmentContext = new HostAssignmentContext
{
SiteId = 1234,
SiteName = "TestApp",
Environment = environment
};
var encryptedAssignmentContext = CreateEncryptedContext(assignmentContext, encryptionKey);
var encryptedAssignmentContext = CreateHostAssignmentRequest(assignmentContext, encryptionKey, useEncryptedPayload);
string json = JsonConvert.SerializeObject(encryptedAssignmentContext);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
request.Headers.Add(AuthenticationLevelHandler.FunctionsKeyHeaderName, masterKey);
var response = await _httpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

private static EncryptedHostAssignmentContext CreateEncryptedContext(HostAssignmentContext context, string key)
private static HostAssignmentRequest CreateHostAssignmentRequest(HostAssignmentContext context, string key, bool useEncryptedPayload)
{
string json = JsonConvert.SerializeObject(context);
var encryptionKey = Convert.FromBase64String(key);
string encrypted = EncryptionHelper.Encrypt(json, encryptionKey);

return new EncryptedHostAssignmentContext { EncryptedContext = encrypted };

if (useEncryptedPayload)
{
return new HostAssignmentRequest { EncryptedContext = encrypted };
}
return new HostAssignmentRequest { AssignmentContext = context };
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,14 @@ public async Task Assign_MSISpecializationFailure_ReturnsError()

var encryptedHostAssignmentValue = EncryptionHelper.Encrypt(JsonConvert.SerializeObject(hostAssignmentContext), TestHelpers.EncryptionKey.ToKeyBytes());

var encryptedHostAssignmentContext = new EncryptedHostAssignmentContext()
var hostAssignmentRequest = new HostAssignmentRequest()
{
EncryptedContext = encryptedHostAssignmentValue
};

environment.SetEnvironmentVariable(EnvironmentSettingNames.ContainerEncryptionKey, TestHelpers.EncryptionKey);

IActionResult result = await instanceController.Assign(encryptedHostAssignmentContext);
IActionResult result = await instanceController.Assign(hostAssignmentRequest);

var objectResult = result as ObjectResult;

Expand Down Expand Up @@ -156,14 +156,14 @@ public async Task Assignment_Sets_Secrets_Context()

var encryptedHostAssignmentValue = EncryptionHelper.Encrypt(JsonConvert.SerializeObject(hostAssignmentContext), TestHelpers.EncryptionKey.ToKeyBytes());

var encryptedHostAssignmentContext = new EncryptedHostAssignmentContext()
var hostAssignmentRequest = new HostAssignmentRequest()
{
EncryptedContext = encryptedHostAssignmentValue
};

environment.SetEnvironmentVariable(EnvironmentSettingNames.ContainerEncryptionKey, TestHelpers.EncryptionKey);

await instanceController.Assign(encryptedHostAssignmentContext);
await instanceController.Assign(hostAssignmentRequest);
Assert.NotNull(startupContextProvider.Context);
}

Expand Down Expand Up @@ -209,21 +209,23 @@ public async Task Assignment_Does_Not_Set_Secrets_Context_For_Warmup_Request()

var encryptedHostAssignmentValue = EncryptionHelper.Encrypt(JsonConvert.SerializeObject(hostAssignmentContext), TestHelpers.EncryptionKey.ToKeyBytes());

var encryptedHostAssignmentContext = new EncryptedHostAssignmentContext()
var hostAssignmentRequest = new HostAssignmentRequest()
{
EncryptedContext = encryptedHostAssignmentValue
};

environment.SetEnvironmentVariable(EnvironmentSettingNames.ContainerEncryptionKey, TestHelpers.EncryptionKey);

await instanceController.Assign(encryptedHostAssignmentContext);
await instanceController.Assign(hostAssignmentRequest);
Assert.Null(startupContextProvider.Context);
}

[Theory]
[InlineData(true, true)]
[InlineData(false, true)]
public async Task Assignment_Invokes_InstanceManager_Methods_For_Warmup_Requests_Also(bool isWarmupRequest, bool shouldInvokeMethod)
[InlineData(true, true, true)]
[InlineData(false, true, true)]
[InlineData(true, true, false)]
[InlineData(false, true, false)]
public async Task Assignment_Invokes_InstanceManager_Methods_For_Warmup_Requests_Also(bool isWarmupRequest, bool shouldInvokeMethod, bool useEncryptedPayload)
{
var environment = new TestEnvironment();
environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "1");
Expand All @@ -250,14 +252,18 @@ public async Task Assignment_Invokes_InstanceManager_Methods_For_Warmup_Requests
EncryptionHelper.Encrypt(JsonConvert.SerializeObject(hostAssignmentContext),
TestHelpers.EncryptionKey.ToKeyBytes());

var encryptedHostAssignmentContext = new EncryptedHostAssignmentContext()
var hostAssignmentRequest = new HostAssignmentRequest() { };
if (useEncryptedPayload)
{
EncryptedContext = encryptedHostAssignmentValue
};

hostAssignmentRequest.EncryptedContext = encryptedHostAssignmentValue;
}
else
{
hostAssignmentRequest.AssignmentContext = hostAssignmentContext;
}
environment.SetEnvironmentVariable(EnvironmentSettingNames.ContainerEncryptionKey, TestHelpers.EncryptionKey);

await instanceController.Assign(encryptedHostAssignmentContext);
await instanceController.Assign(hostAssignmentRequest);

instanceManager.Verify(i => i.ValidateContext(It.IsAny<HostAssignmentContext>()),
shouldInvokeMethod ? Times.Once() : Times.Never());
Expand All @@ -266,5 +272,36 @@ public async Task Assignment_Invokes_InstanceManager_Methods_For_Warmup_Requests
instanceManager.Verify(i => i.StartAssignment(It.IsAny<HostAssignmentContext>()),
shouldInvokeMethod ? Times.Once() : Times.Never());
}

[Fact]
public async Task Assignment_ErrorScenarios()
{
var environment = new TestEnvironment();
environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "1");
var loggerFactory = new LoggerFactory();
var loggerProvider = new TestLoggerProvider();
loggerFactory.AddProvider(loggerProvider);
var instanceController = new InstanceController(environment, null, loggerFactory, null);

// Both encrypted and unencrypted context are null
var hostAssignmentRequest = new HostAssignmentRequest() { };
var result = await instanceController.Assign(hostAssignmentRequest);
var badRequestResult = result as BadRequestObjectResult;
Assert.NotNull(badRequestResult);
Assert.Equal(400, badRequestResult.StatusCode);
Assert.Equal("Atleast one of Assignment context and EncryptedContext needs to be set.", badRequestResult.Value);

// Both encrypted and unencrypted context are set
hostAssignmentRequest = new HostAssignmentRequest()
{
EncryptedContext = "EncryptedContext",
AssignmentContext = new HostAssignmentContext()
};
result = await instanceController.Assign(hostAssignmentRequest);
badRequestResult = result as BadRequestObjectResult;
Assert.NotNull(badRequestResult);
Assert.Equal(400, badRequestResult.StatusCode);
Assert.Equal("Only one of Assignment context and EncryptedContext needs to be set.", badRequestResult.Value);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task AssignInstanceAsyncIsAwaitedTest()
_mockInstanceManager.Setup(m => m.AssignInstanceAsync(It.IsAny<HostAssignmentContext>())).Returns(tcs.Task);

var assignmentContext = new HostAssignmentContext();
_mockStartupContextProvider.Setup(p => p.SetContext(It.IsAny<EncryptedHostAssignmentContext>())).Returns(assignmentContext);
_mockStartupContextProvider.Setup(p => p.SetContext(It.IsAny<HostAssignmentRequest>())).Returns(assignmentContext);

var service = new TestLinuxContainerInitializationHostedService(GetTestEnvironment(), _mockInstanceManager.Object,
_mockLogger.Object, _mockStartupContextProvider.Object);
Expand Down Expand Up @@ -65,9 +65,9 @@ public TestLinuxContainerInitializationHostedService(IEnvironment environment, I

protected override Task<(bool HasStartContext, string StartContext)> TryGetStartContextOrNullAsync(CancellationToken cancellationToken)
{
var encryptedAssignmentContext = new EncryptedHostAssignmentContext { EncryptedContext = "test" };
var hostAssignmentRequest = new HostAssignmentRequest { EncryptedContext = "test" };

return Task.FromResult((true, JsonConvert.SerializeObject(encryptedAssignmentContext)));
return Task.FromResult((true, JsonConvert.SerializeObject(hostAssignmentRequest)));
}

protected override Task SpecializeMSISideCar(HostAssignmentContext assignmentContext)
Expand Down
6 changes: 3 additions & 3 deletions test/WebJobs.Script.Tests/StartupContextProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public void SetContext_AppliesHostAssignmentContext()
};
string json = JsonConvert.SerializeObject(context);
string encrypted = EncryptionHelper.Encrypt(json, environment: _environment);
var encryptedContext = new EncryptedHostAssignmentContext { EncryptedContext = encrypted };
var encryptedContext = new HostAssignmentRequest { EncryptedContext = encrypted };

var result = _startupContextProvider.SetContext(encryptedContext);
Assert.Equal(context.SiteName, result.SiteName);
Expand All @@ -212,9 +212,9 @@ public void Does_Not_SetContext_AppliesHostAssignmentContext_For_Warmup_Request(
};
string json = JsonConvert.SerializeObject(context);
string encrypted = EncryptionHelper.Encrypt(json, environment: _environment);
var encryptedContext = new EncryptedHostAssignmentContext { EncryptedContext = encrypted };
var hostAssignmentRequest = new HostAssignmentRequest { EncryptedContext = encrypted };

var result = _startupContextProvider.SetContext(encryptedContext);
var result = _startupContextProvider.SetContext(hostAssignmentRequest);
Assert.Equal(context.SiteName, result.SiteName);
Assert.Equal(_secrets.Host.Master, result.Secrets.Host.Master);

Expand Down