diff --git a/eng/ci/publish.yml b/eng/ci/publish.yml new file mode 100644 index 000000000..8b65519fa --- /dev/null +++ b/eng/ci/publish.yml @@ -0,0 +1,99 @@ +# This is our package-publishing pipeline. +# When executed, it automatically publishes the output of the 'official pipeline' (the nupkgs) to our internal ADO feed. +# It may optionally also publish the packages to NuGet, but that is gated behind a manual approval. + +trigger: none # only trigger is manual +pr: none # only trigger is manual + +# We include to this variable group to be able to access the NuGet API key +variables: +- group: durabletask_config + +resources: + repositories: + - repository: 1es + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + - repository: eng + type: git + name: engineering + ref: refs/tags/release + + pipelines: + - pipeline: officialPipeline # Reference to the pipeline to be used as an artifact source + source: 'durable-extension.official' + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1es + parameters: + pool: + name: 1es-pool-azfunc + image: 1es-windows-2022 + os: windows + + stages: + - stage: release + jobs: + + # ADO release + - job: adoRelease + displayName: ADO Release + templateContext: + inputs: + - input: pipelineArtifact + pipeline: officialPipeline # Pipeline reference, as defined in the resources section + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + + # The preferred method of release on 1ES is by populating the 'output' section of a 1ES template. + # We use this method to release to ADO, but not to release to NuGet; this is explained in the 'nugetRelease' job. + # To read more about the 'output syntax', see: + # - https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/outputs + # - https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/outputs/nuget-packages + outputs: + - output: nuget # 'nuget' is an output "type" for pushing to NuGet + displayName: 'Push to durabletask ADO feed' + packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling + packagesToPush: '$(System.DefaultWorkingDirectory)/**/*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' + publishVstsFeed: '3f99e810-c336-441f-8892-84983093ad7f/c895696b-ce37-4fe7-b7ce-74333a04f8bf' + allowPackageConflicts: true + + # NuGet approval gate + - job: nugetApproval + displayName: NuGetApproval + pool: server # This task only works when executed on serverl pools, so this needs to be specified + steps: + # Wait for manual approval. + - task: ManualValidation@1 + inputs: + instructions: Confirm you want to push to NuGet + onTimeout: 'reject' + + # NuGet release + - job: nugetRelease + displayName: NuGet Release + dependsOn: + - nugetApproval + - adoRelease + condition: succeeded('nugetApproval', 'adoRelease') + templateContext: + inputs: + - input: pipelineArtifact + pipeline: officialPipeline # Pipeline reference as defined in the resources section + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + # Ideally, we would push to NuGet using the 1ES "template output" syntax, like we do for ADO. + # Unfortunately, that syntax does not allow for skipping duplicates when pushing to NuGet feeds + # (i.e; not failing the job when trying to push a package version that already exists on NuGet). + # This is a problem for us because our pipelines often produce multiple packages, and we want to be able to + # perform a 'nuget push *.nupkg' that skips packages already on NuGet while pushing the rest. + # Therefore, we use a regular .NET Core ADO Task to publish the packages until that usability gap is addressed. + steps: + - task: DotNetCoreCLI@2 + displayName: 'Push to nuget.org' + inputs: + command: custom + custom: nuget + arguments: 'push "*.nupkg" --api-key $(nuget_api_key) --skip-duplicate --source https://api.nuget.org/v3/index.json' + workingDirectory: '$(System.DefaultWorkingDirectory)/drop' \ No newline at end of file diff --git a/eng/templates/build.yml b/eng/templates/build.yml index 3e3e41040..c68e0d44d 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -124,3 +124,14 @@ jobs: SourceFolder: '$(System.DefaultWorkingDirectory)/test/PerfTests/DFPerfTests/Output/' Contents: '**' TargetFolder: '$(System.DefaultWorkingDirectory)/azure-functions-durable-extension/' + + # We also need to build the Java smoke test, for CodeQL compliance + # We don't need to build the other smoke tests, because they can be analyzed without being compiled, + # as they're interpreted languages. + # This could be a separate pipeline, but the task is so small that it's paired with the .NET code build + # for convenience. + - pwsh: | + cd ./test/SmokeTests/OOProcSmokeTests/durableJava/ + gradle build + ls + displayName: 'Build Java OOProc test (for CodeQL compliance)' \ No newline at end of file diff --git a/release_notes.md b/release_notes.md index 7e798a533..3babbb06b 100644 --- a/release_notes.md +++ b/release_notes.md @@ -4,10 +4,13 @@ ### New Features +- Fail fast if extendedSessionsEnabled set to 'true' for the worker type that doesn't support extended sessions (https://github.com/Azure/azure-functions-durable-extension/pull/2732). +- Added an `IFunctionsWorkerApplicationBuilder.ConfigureDurableExtension()` extension method for cases where auto-registration does not work (no source gen running). (#2950) + ### Bug Fixes -- Fix support for distributed tracing v2 in dotnet-isolated and Java (https://github.com/Azure/azure-functions-durable-extension/pull/2634) -- Update Microsoft.DurableTask.\* dependencies to v1.0.5 +- Fix custom connection name not working when using IDurableClientFactory.CreateClient() - contributed by [@hctan](https://github.com/hctan) +- Made durable extension for isolated worker configuration idempotent, allowing multiple calls safely. (#2950) ### Breaking Changes diff --git a/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.Designer.cs b/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.Designer.cs index d72cd9b48..b3d180153 100644 --- a/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.Designer.cs +++ b/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.Designer.cs @@ -621,7 +621,7 @@ public static string IOTypesAnalyzerTitle { } /// - /// Looks up a localized string similar to Method call '{0}' violates the orchestrator deterministic code constraint. Methods definied in source code that are used in an orchestrator must be deterministic.. + /// Looks up a localized string similar to Method call '{0}' violates the orchestrator deterministic code constraint. Methods defined in source code that are used in an orchestrator must be deterministic.. /// public static string MethodAnalyzerMessageFormat { get { @@ -630,7 +630,7 @@ public static string MethodAnalyzerMessageFormat { } /// - /// Looks up a localized string similar to Methods definied in source code that are used in an orchestrator must be deterministic.. + /// Looks up a localized string similar to Methods defined in source code that are used in an orchestrator must be deterministic.. /// public static string MethodAnalyzerTitle { get { diff --git a/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.resx b/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.resx index d40722bfb..1eb6cd2a4 100644 --- a/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.resx +++ b/src/WebJobs.Extensions.DurableTask.Analyzers/Resources.resx @@ -306,10 +306,10 @@ https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-check I/O operations are not allowed inside an orchestrator function. - Method call '{0}' violates the orchestrator deterministic code constraint. Methods definied in source code that are used in an orchestrator must be deterministic. + Method call '{0}' violates the orchestrator deterministic code constraint. Methods defined in source code that are used in an orchestrator must be deterministic. - Methods definied in source code that are used in an orchestrator must be deterministic. + Methods defined in source code that are used in an orchestrator must be deterministic. SignalEntityAsync must use an Entity Interface. diff --git a/src/WebJobs.Extensions.DurableTask/Bindings/BindingHelper.cs b/src/WebJobs.Extensions.DurableTask/Bindings/BindingHelper.cs index a0c2fddde..499628929 100644 --- a/src/WebJobs.Extensions.DurableTask/Bindings/BindingHelper.cs +++ b/src/WebJobs.Extensions.DurableTask/Bindings/BindingHelper.cs @@ -43,6 +43,7 @@ public string DurableOrchestrationClientToString(IDurableOrchestrationClient cli ConnectionName = attr.ConnectionName, RpcBaseUrl = localRpcAddress, RequiredQueryStringParameters = this.config.HttpApiHandler.GetUniversalQueryStrings(), + HttpBaseUrl = this.config.HttpApiHandler.GetBaseUrl(), }); } @@ -130,6 +131,14 @@ private class OrchestrationClientInputData /// [JsonProperty("rpcBaseUrl")] public string? RpcBaseUrl { get; set; } + + /// + /// The base URL of the Azure Functions host, used in the out-of-proc model. + /// This URL is sent by the client binding object to the Durable Worker extension, + /// allowing the extension to know the host's base URL for constructing management URLs. + /// + [JsonProperty("httpBaseUrl")] + public string? HttpBaseUrl { get; set; } } } } diff --git a/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs b/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs index 59c834382..4d78042e2 100644 --- a/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs +++ b/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs @@ -293,7 +293,7 @@ void IExtensionConfigProvider.Initialize(ExtensionConfigContext context) } // Throw if any of the configured options are invalid - this.Options.Validate(this.nameResolver, this.TraceHelper); + this.Options.Validate(this.nameResolver); #pragma warning disable CS0618 // Type or member is obsolete diff --git a/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml b/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml index 4474db3ce..0333a07d3 100644 --- a/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml +++ b/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml @@ -51,6 +51,13 @@ HTTP endpoint. For out-of-proc "v2" (middelware passthrough), this is a gRPC endpoint. + + + The base URL of the Azure Functions host, used in the out-of-proc model. + This URL is sent by the client binding object to the Durable Worker extension, + allowing the extension to know the host's base URL for constructing management URLs. + + The result of a clean entity storage operation. diff --git a/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs b/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs index bc555386f..c7322044e 100644 --- a/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs +++ b/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs @@ -302,7 +302,7 @@ internal void TraceConfiguration(EndToEndTraceHelper traceHelper, JObject storag traceHelper.TraceConfiguration(this.HubName, configurationJson.ToString(Formatting.None)); } - internal void Validate(INameResolver environmentVariableResolver, EndToEndTraceHelper traceHelper) + internal void Validate(INameResolver environmentVariableResolver) { if (string.IsNullOrEmpty(this.HubName)) { @@ -320,12 +320,9 @@ internal void Validate(INameResolver environmentVariableResolver, EndToEndTraceH runtimeLanguage != null && // If we don't know from the environment variable, don't assume customer isn't .NET !string.Equals(runtimeLanguage, "dotnet", StringComparison.OrdinalIgnoreCase)) { - traceHelper.ExtensionWarningEvent( - hubName: this.HubName, - functionName: string.Empty, - instanceId: string.Empty, - message: "Durable Functions does not work with extendedSessions = true for non-.NET languages. This value is being set to false instead. See https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-perf-and-scale#extended-sessions for more details."); - this.ExtendedSessionsEnabled = false; + throw new InvalidOperationException( + "Durable Functions with extendedSessionsEnabled set to 'true' is only supported when using the in-process .NET worker. Please remove the setting or change it to 'false'." + + "See https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-perf-and-scale#extended-sessions for more details."); } this.Notifications.Validate(); diff --git a/src/Worker.Extensions.DurableTask/DurableTaskClientConverter.cs b/src/Worker.Extensions.DurableTask/DurableTaskClientConverter.cs index 1d3da9003..2cfc2706e 100644 --- a/src/Worker.Extensions.DurableTask/DurableTaskClientConverter.cs +++ b/src/Worker.Extensions.DurableTask/DurableTaskClientConverter.cs @@ -49,7 +49,7 @@ public ValueTask ConvertAsync(ConverterContext context) } DurableTaskClient client = this.clientProvider.GetClient(endpoint, inputData?.taskHubName, inputData?.connectionName); - client = new FunctionsDurableTaskClient(client, inputData!.requiredQueryStringParameters); + client = new FunctionsDurableTaskClient(client, inputData!.requiredQueryStringParameters, inputData!.httpBaseUrl); return new ValueTask(ConversionResult.Success(client)); } catch (Exception innerException) @@ -62,5 +62,5 @@ public ValueTask ConvertAsync(ConverterContext context) } // Serializer is case-sensitive and incoming JSON properties are camel-cased. - private record DurableClientInputData(string rpcBaseUrl, string taskHubName, string connectionName, string requiredQueryStringParameters); + private record DurableClientInputData(string rpcBaseUrl, string taskHubName, string connectionName, string requiredQueryStringParameters, string httpBaseUrl); } diff --git a/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs b/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs index 286c206fe..251ebb2d7 100644 --- a/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs +++ b/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; +using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -18,6 +19,70 @@ namespace Microsoft.Azure.Functions.Worker; /// public static class DurableTaskClientExtensions { + /// + /// Waits for the completion of the specified orchestration instance with a retry interval, controlled by the cancellation token. + /// If the orchestration does not complete within the required time, returns an HTTP response containing the class to manage instances. + /// + /// The . + /// The HTTP request that this response is for. + /// The ID of the orchestration instance to check. + /// The timeout between checks for output from the durable function. The default value is 1 second. + /// Optional parameter that configures the http response code returned. Defaults to false. + /// Optional parameter that configures whether to get the inputs and outputs of the orchestration. Defaults to false. + /// A token that signals if the wait should be canceled. If canceled, call CreateCheckStatusResponseAsync to return a reponse contains a HttpManagementPayload. + /// + public static async Task WaitForCompletionOrCreateCheckStatusResponseAsync( + this DurableTaskClient client, + HttpRequestData request, + string instanceId, + TimeSpan? retryInterval = null, + bool returnInternalServerErrorOnFailure = false, + bool getInputsAndOutputs = false, + CancellationToken cancellation = default + ) + { + TimeSpan retryIntervalLocal = retryInterval ?? TimeSpan.FromSeconds(1); + try + { + while (true) + { + var status = await client.GetInstanceAsync(instanceId, getInputsAndOutputs: getInputsAndOutputs); + if (status != null) + { + if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed || +#pragma warning disable CS0618 // Type or member is obsolete + status.RuntimeStatus == OrchestrationRuntimeStatus.Canceled || +#pragma warning restore CS0618 // Type or member is obsolete + status.RuntimeStatus == OrchestrationRuntimeStatus.Terminated || + status.RuntimeStatus == OrchestrationRuntimeStatus.Failed) + { + var response = request.CreateResponse( + (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed && returnInternalServerErrorOnFailure) ? HttpStatusCode.InternalServerError : HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + Name = status.Name, + InstanceId = status.InstanceId, + CreatedAt = status.CreatedAt, + LastUpdatedAt = status.LastUpdatedAt, + RuntimeStatus = status.RuntimeStatus.ToString(), // Convert enum to string + SerializedInput = status.SerializedInput, + SerializedOutput = status.SerializedOutput, + SerializedCustomStatus = status.SerializedCustomStatus + }, statusCode: response.StatusCode); + + return response; + } + } + await Task.Delay(retryIntervalLocal, cancellation); + } + } + // If the task is canceled, call CreateCheckStatusResponseAsync to return a response containing instance management URLs. + catch (OperationCanceledException) + { + return await CreateCheckStatusResponseAsync(client, request, instanceId); + } + } + /// /// Creates an HTTP response that is useful for checking the status of the specified instance. /// @@ -120,8 +185,30 @@ public static HttpResponseData CreateCheckStatusResponse( return response; } - private static object SetHeadersAndGetPayload( - DurableTaskClient client, HttpRequestData request, HttpResponseData response, string instanceId) + /// + /// Creates an HTTP management payload for the specified orchestration instance. + /// + /// The . + /// The ID of the orchestration instance. + /// Optional HTTP request data to use for creating the base URL. + /// An object containing instance control URLs. + /// Thrown when instanceId is null or empty. + /// Thrown when a valid base URL cannot be determined. + public static HttpManagementPayload CreateHttpManagementPayload( + this DurableTaskClient client, + string instanceId, + HttpRequestData? request = null) + { + if (string.IsNullOrEmpty(instanceId)) + { + throw new ArgumentException("InstanceId cannot be null or empty.", nameof(instanceId)); + } + + return SetHeadersAndGetPayload(client, request, null, instanceId); + } + + private static HttpManagementPayload SetHeadersAndGetPayload( + DurableTaskClient client, HttpRequestData? request, HttpResponseData? response, string instanceId) { static string BuildUrl(string url, params string?[] queryValues) { @@ -143,22 +230,46 @@ static string BuildUrl(string url, params string?[] queryValues) // request headers into consideration and generate the base URL accordingly. // More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded. // One potential workaround is to set ASPNETCORE_FORWARDEDHEADERS_ENABLED to true. - string baseUrl = request.Url.GetLeftPart(UriPartial.Authority); + + // If HttpRequestData is provided, use its URL; otherwise, get the baseUrl from the DurableTaskClient. + // The base URL could be null if: + // 1. The DurableTaskClient isn't a FunctionsDurableTaskClient (which would have the baseUrl from bindings) + // 2. There's no valid HttpRequestData provided + string? baseUrl = ((request != null) ? GetBaseUrlFromRequest(request) : GetBaseUrl(client)); + + if (baseUrl == null) + { + throw new InvalidOperationException("Failed to create HTTP management payload as base URL is null. Either use Functions bindings or provide an HTTP request to create the HttpPayload."); + } + + bool isFromRequest = request != null; + string formattedInstanceId = Uri.EscapeDataString(instanceId); - string instanceUrl = $"{baseUrl}/runtime/webhooks/durabletask/instances/{formattedInstanceId}"; + + // The baseUrl differs depending on the source. Eg: + // - From request: http://localhost:7071/ + // - From durable client: http://localhost:7071/runtime/webhooks/durabletask + // We adjust the instanceUrl construction accordingly. + string instanceUrl = isFromRequest + ? $"{baseUrl}/runtime/webhooks/durabletask/instances/{formattedInstanceId}" + : $"{baseUrl}/instances/{formattedInstanceId}"; string? commonQueryParameters = GetQueryParams(client); - response.Headers.Add("Location", BuildUrl(instanceUrl, commonQueryParameters)); - response.Headers.Add("Content-Type", "application/json"); + + if (response != null) + { + response.Headers.Add("Location", BuildUrl(instanceUrl, commonQueryParameters)); + response.Headers.Add("Content-Type", "application/json"); + } - return new + return new HttpManagementPayload { - id = instanceId, - purgeHistoryDeleteUri = BuildUrl(instanceUrl, commonQueryParameters), - sendEventPostUri = BuildUrl($"{instanceUrl}/raiseEvent/{{eventName}}", commonQueryParameters), - statusQueryGetUri = BuildUrl(instanceUrl, commonQueryParameters), - terminatePostUri = BuildUrl($"{instanceUrl}/terminate", "reason={{text}}", commonQueryParameters), - suspendPostUri = BuildUrl($"{instanceUrl}/suspend", "reason={{text}}", commonQueryParameters), - resumePostUri = BuildUrl($"{instanceUrl}/resume", "reason={{text}}", commonQueryParameters) + Id = instanceId, + PurgeHistoryDeleteUri = BuildUrl(instanceUrl, commonQueryParameters), + SendEventPostUri = BuildUrl($"{instanceUrl}/raiseEvent/{{eventName}}", commonQueryParameters), + StatusQueryGetUri = BuildUrl(instanceUrl, commonQueryParameters), + TerminatePostUri = BuildUrl($"{instanceUrl}/terminate", "reason={{text}}", commonQueryParameters), + SuspendPostUri = BuildUrl($"{instanceUrl}/suspend", "reason={{text}}", commonQueryParameters), + ResumePostUri = BuildUrl($"{instanceUrl}/resume", "reason={{text}}", commonQueryParameters) }; } @@ -168,8 +279,58 @@ private static ObjectSerializer GetObjectSerializer(HttpResponseData response) ?? throw new InvalidOperationException("A serializer is not configured for the worker."); } + private static string? GetBaseUrlFromRequest(HttpRequestData request) + { + // Default to the scheme from the request URL + string proto = request.Url.Scheme; + string host = request.Url.Authority; + + // Check for "Forwarded" header + if (request.Headers.TryGetValues("Forwarded", out var forwardedHeaders)) + { + var forwardedDict = forwardedHeaders.FirstOrDefault()?.Split(';') + .Select(pair => pair.Split('=')) + .Where(pair => pair.Length == 2) + .ToDictionary(pair => pair[0].Trim(), pair => pair[1].Trim()); + + if (forwardedDict != null) + { + if (forwardedDict.TryGetValue("proto", out var forwardedProto)) + { + proto = forwardedProto; + } + if (forwardedDict.TryGetValue("host", out var forwardedHost)) + { + host = forwardedHost; + // Return if either proto or host (or both) were found in "Forwarded" header + return $"{proto}://{forwardedHost}"; + } + } + } + // Check for "X-Forwarded-Proto" and "X-Forwarded-Host" headers if "Forwarded" is not present + if (request.Headers.TryGetValues("X-Forwarded-Proto", out var protos)) + { + proto = protos.FirstOrDefault() ?? proto; + } + if (request.Headers.TryGetValues("X-Forwarded-Host", out var hosts)) + { + // Return base URL if either "X-Forwarded-Proto" or "X-Forwarded-Host" (or both) are found + host = hosts.FirstOrDefault() ?? host; + return $"{proto}://{host}"; + } + + // Construct and return the base URL from default fallback values + return $"{proto}://{host}"; + } + + private static string? GetQueryParams(DurableTaskClient client) { return client is FunctionsDurableTaskClient functions ? functions.QueryString : null; } + + private static string? GetBaseUrl(DurableTaskClient client) + { + return client is FunctionsDurableTaskClient functions ? functions.HttpBaseUrl : null; + } } diff --git a/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs b/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs index 626acd6bf..af7bab017 100644 --- a/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs +++ b/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs @@ -1,20 +1,8 @@ // 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 Azure.Core.Serialization; using Microsoft.Azure.Functions.Worker.Core; using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Converters; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.Shims; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; [assembly: WorkerExtensionStartup(typeof(DurableTaskExtensionStartup))] @@ -28,49 +16,6 @@ public sealed class DurableTaskExtensionStartup : WorkerExtensionStartup /// public override void Configure(IFunctionsWorkerApplicationBuilder applicationBuilder) { - applicationBuilder.Services.AddSingleton(); - applicationBuilder.Services.AddOptions() - .Configure(options => options.EnableEntitySupport = true) - .PostConfigure((opt, sp) => - { - if (GetConverter(sp) is DataConverter converter) - { - opt.DataConverter = converter; - } - }); - - applicationBuilder.Services.AddOptions() - .Configure(options => options.EnableEntitySupport = true) - .PostConfigure((opt, sp) => - { - if (GetConverter(sp) is DataConverter converter) - { - opt.DataConverter = converter; - } - }); - - applicationBuilder.Services.TryAddSingleton(sp => - { - DurableTaskWorkerOptions options = sp.GetRequiredService>().Value; - ILoggerFactory factory = sp.GetRequiredService(); - return new DurableTaskShimFactory(options, factory); // For GrpcOrchestrationRunner - }); - - applicationBuilder.Services.Configure(o => - { - o.InputConverters.Register(); - }); - - applicationBuilder.UseMiddleware(); - } - - private static DataConverter? GetConverter(IServiceProvider services) - { - // We intentionally do not consider a DataConverter in the DI provider, or if one was already set. This is to - // ensure serialization is consistent with the rest of Azure Functions. This is particularly important because - // TaskActivity bindings use ObjectSerializer directly for the time being. Due to this, allowing DataConverter - // to be set separately from ObjectSerializer would give an inconsistent serialization solution. - WorkerOptions? worker = services.GetRequiredService>()?.Value; - return worker?.Serializer is not null ? new ObjectConverterShim(worker.Serializer) : null; + applicationBuilder.ConfigureDurableExtension(); } } diff --git a/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs b/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs index 0f9231375..3c919362d 100644 --- a/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs +++ b/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs @@ -17,15 +17,16 @@ internal sealed class FunctionsDurableTaskClient : DurableTaskClient { private readonly DurableTaskClient inner; - public FunctionsDurableTaskClient(DurableTaskClient inner, string? queryString) + public FunctionsDurableTaskClient(DurableTaskClient inner, string? queryString, string? httpBaseUrl) : base(inner.Name) { this.inner = inner; this.QueryString = queryString; + this.HttpBaseUrl = httpBaseUrl; } public string? QueryString { get; } - + public string? HttpBaseUrl { get; } public override DurableEntityClient Entities => this.inner.Entities; public override ValueTask DisposeAsync() diff --git a/src/Worker.Extensions.DurableTask/FunctionsWorkerApplicationBuilderExtensions.cs b/src/Worker.Extensions.DurableTask/FunctionsWorkerApplicationBuilderExtensions.cs new file mode 100644 index 000000000..642446dd4 --- /dev/null +++ b/src/Worker.Extensions.DurableTask/FunctionsWorkerApplicationBuilderExtensions.cs @@ -0,0 +1,125 @@ +// 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.Linq; +using Azure.Core.Serialization; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Converters; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.Shims; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.Functions.Worker; + +/// +/// Extensions for . +/// +public static class FunctionsWorkerApplicationBuilderExtensions +{ + /// + /// Configures the Durable Functions extension for the worker. + /// + /// The builder to configure. + /// The for call chaining. + public static IFunctionsWorkerApplicationBuilder ConfigureDurableExtension(this IFunctionsWorkerApplicationBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.TryAddSingleton(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, ConfigureClientOptions>()); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, PostConfigureClientOptions>()); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, ConfigureWorkerOptions>()); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, PostConfigureWorkerOptions>()); + + builder.Services.TryAddSingleton(sp => + { + DurableTaskWorkerOptions options = sp.GetRequiredService>().Value; + ILoggerFactory factory = sp.GetRequiredService(); + return new DurableTaskShimFactory(options, factory); // For GrpcOrchestrationRunner + }); + + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, ConfigureInputConverter>()); + if (!builder.Services.Any(d => d.ServiceType == typeof(DurableTaskFunctionsMiddleware))) + { + builder.UseMiddleware(); + } + + return builder; + } + + private class ConfigureInputConverter : IConfigureOptions + { + public void Configure(WorkerOptions options) + { + options.InputConverters.Register(); + } + } + + private class ConfigureClientOptions : IConfigureOptions + { + public void Configure(DurableTaskClientOptions options) + { + options.EnableEntitySupport = true; + } + } + + private class PostConfigureClientOptions : IPostConfigureOptions + { + readonly IOptionsMonitor workerOptions; + + public PostConfigureClientOptions(IOptionsMonitor workerOptions) + { + this.workerOptions = workerOptions; + } + + public void PostConfigure(string name, DurableTaskClientOptions options) + { + if (this.workerOptions.Get(name).Serializer is { } serializer) + { + options.DataConverter = new ObjectConverterShim(serializer); + } + } + } + + private class ConfigureWorkerOptions : IConfigureOptions + { + public void Configure(DurableTaskWorkerOptions options) + { + options.EnableEntitySupport = true; + } + } + + private class PostConfigureWorkerOptions : IPostConfigureOptions + { + readonly IOptionsMonitor workerOptions; + + public PostConfigureWorkerOptions(IOptionsMonitor workerOptions) + { + this.workerOptions = workerOptions; + } + + public void PostConfigure(string name, DurableTaskWorkerOptions options) + { + if (this.workerOptions.Get(name).Serializer is { } serializer) + { + options.DataConverter = new ObjectConverterShim(serializer); + } + } + } +} diff --git a/src/Worker.Extensions.DurableTask/HttpManagementPayload.cs b/src/Worker.Extensions.DurableTask/HttpManagementPayload.cs new file mode 100644 index 000000000..4e7228f87 --- /dev/null +++ b/src/Worker.Extensions.DurableTask/HttpManagementPayload.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// This is a copy of: https://github.com/Azure/azure-functions-durable-extension/blob/dev/src/WebJobs.Extensions.DurableTask/HttpManagementPayload.cs + +using System; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Functions.Worker; + +/// +/// Data structure containing status, terminate and send external event HTTP endpoints. +/// +public class HttpManagementPayload +{ + /// + /// Gets the ID of the orchestration instance. + /// + /// + /// The ID of the orchestration instance. + /// + [JsonProperty("id")] + public string? Id { get; internal set; } + + /// + /// Gets the HTTP GET status query endpoint URL. + /// + /// + /// The HTTP URL for fetching the instance status. + /// + [JsonProperty("statusQueryGetUri")] + public string? StatusQueryGetUri { get; internal set; } + + /// + /// Gets the HTTP POST external event sending endpoint URL. + /// + /// + /// The HTTP URL for posting external event notifications. + /// + [JsonProperty("sendEventPostUri")] + public string? SendEventPostUri { get; internal set; } + + /// + /// Gets the HTTP POST instance termination endpoint. + /// + /// + /// The HTTP URL for posting instance termination commands. + /// + [JsonProperty("terminatePostUri")] + public string? TerminatePostUri { get; internal set; } + + /// + /// Gets the HTTP POST instance rewind endpoint. + /// + /// + /// The HTTP URL for rewinding orchestration instances. + /// + [JsonProperty("rewindPostUri")] + public string? RewindPostUri { get; internal set; } + + /// + /// Gets the HTTP DELETE purge instance history by instance ID endpoint. + /// + /// + /// The HTTP URL for purging instance history by instance ID. + /// + [JsonProperty("purgeHistoryDeleteUri")] + public string? PurgeHistoryDeleteUri { get; internal set; } + + /// + /// Gets the HTTP POST instance restart endpoint. + /// + /// + /// The HTTP URL for restarting an orchestration instance. + /// + [JsonProperty("restartPostUri")] + public string? RestartPostUri { get; internal set; } + + /// + /// Gets the HTTP POST instance suspend endpoint. + /// + /// + /// The HTTP URL for suspending an orchestration instance. + /// + [JsonProperty("suspendPostUri")] + public string? SuspendPostUri { get; internal set; } + + /// + /// Gets the HTTP POST instance resume endpoint. + /// + /// + /// The HTTP URL for resuming an orchestration instance. + /// + [JsonProperty("resumePostUri")] + public string? ResumePostUri { get; internal set; } +} diff --git a/test/Common/CustomTestStorageAccountProvider.cs b/test/Common/CustomTestStorageAccountProvider.cs new file mode 100644 index 000000000..6aa62e36e --- /dev/null +++ b/test/Common/CustomTestStorageAccountProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using Azure.Data.Tables; +using Azure.Storage.Blobs; +using Azure.Storage.Queues; +using DurableTask.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Storage; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +{ + internal class CustomTestStorageAccountProvider : IStorageServiceClientProviderFactory + { + private readonly string customConnectionString; + private readonly string customConnectionName; + + public CustomTestStorageAccountProvider(string connectionName) + { + this.customConnectionName = connectionName; + this.customConnectionString = $"DefaultEndpointsProtocol=https;AccountName=test;AccountKey={GenerateRandomKey()};EndpointSuffix=core.windows.net"; + } + + public IStorageServiceClientProvider GetBlobClientProvider(string connectionName) => + StorageServiceClientProvider.ForBlob(TestHelpers.GetStorageConnectionString()); + + public IStorageServiceClientProvider GetQueueClientProvider(string connectionName) => + StorageServiceClientProvider.ForQueue(TestHelpers.GetStorageConnectionString()); + + public IStorageServiceClientProvider GetTableClientProvider(string connectionName) => + StorageServiceClientProvider.ForTable(TestHelpers.GetStorageConnectionString()); + + private static string GenerateRandomKey() + { + string key = Guid.NewGuid().ToString(); + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(key)); + } + } +} diff --git a/test/Common/DurableTaskEndToEndTests.cs b/test/Common/DurableTaskEndToEndTests.cs index 3f7c03b02..bc02cde5a 100644 --- a/test/Common/DurableTaskEndToEndTests.cs +++ b/test/Common/DurableTaskEndToEndTests.cs @@ -5571,16 +5571,24 @@ public async Task ExtendedSessions_OutOfProc_SetToFalse() { "FUNCTIONS_WORKER_RUNTIME", "node" }, }); - using (var host = TestHelpers.GetJobHostWithOptions( - this.loggerProvider, - durableTaskOptions, - nameResolver: nameResolver)) - { - await host.StartAsync(); - await host.StopAsync(); - } + InvalidOperationException exception = + await Assert.ThrowsAsync(async () => + { + using (var host = TestHelpers.GetJobHostWithOptions( + this.loggerProvider, + durableTaskOptions, + nameResolver: nameResolver)) + { + await host.StartAsync(); + await host.StopAsync(); + } + }); - Assert.False(durableTaskOptions.ExtendedSessionsEnabled); + Assert.NotNull(exception); + Assert.StartsWith( + "Durable Functions with extendedSessionsEnabled set to 'true' is only supported when using", + exception.Message, + StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/test/FunctionsV2/AzureStorageDurabilityProviderFactoryTests.cs b/test/FunctionsV2/AzureStorageDurabilityProviderFactoryTests.cs index eea9aab70..17abe7ab7 100644 --- a/test/FunctionsV2/AzureStorageDurabilityProviderFactoryTests.cs +++ b/test/FunctionsV2/AzureStorageDurabilityProviderFactoryTests.cs @@ -154,5 +154,46 @@ public void EnvironmentIsVMSS_WorkerIdFromEnvironmentVariables() Assert.Equal("waws-prod-euapbn1-003:dw0SmallDedicatedWebWorkerRole_hr0HostRole-3-VM-13", settings.WorkerId); } + + [Fact] + [Trait("Category", PlatformSpecificHelpers.TestCategory)] + public void CustomConnectionNameIsResolved() + { + var storageAccountProvider = new CustomTestStorageAccountProvider("CustomConnection"); + var mockOptions = new OptionsWrapper(new DurableTaskOptions()); + var nameResolver = new Mock().Object; + + var factory = new AzureStorageDurabilityProviderFactory( + mockOptions, + storageAccountProvider, + nameResolver, + NullLoggerFactory.Instance, + TestHelpers.GetMockPlatformInformationService()); + + factory.GetDurabilityProvider(); // This will initialize the default connection string + var provider = factory.GetDurabilityProvider(new DurableClientAttribute() { ConnectionName = "CustomConnection", TaskHub = "TestHubName" }); + + Assert.Equal("CustomConnection", provider.ConnectionName); + } + + [Fact] + [Trait("Category", PlatformSpecificHelpers.TestCategory)] + public void DefaultConnectionNameIsResolved() + { + var storageAccountProvider = new CustomTestStorageAccountProvider("CustomConnection"); + var mockOptions = new OptionsWrapper(new DurableTaskOptions()); + var nameResolver = new Mock().Object; + + var factory = new AzureStorageDurabilityProviderFactory( + mockOptions, + storageAccountProvider, + nameResolver, + NullLoggerFactory.Instance, + TestHelpers.GetMockPlatformInformationService()); + + var provider = factory.GetDurabilityProvider(); + + Assert.Equal("Storage", provider.ConnectionName); + } } } diff --git a/test/SmokeTests/e2e-test.ps1 b/test/SmokeTests/e2e-test.ps1 index e03cba218..c73e2d75b 100644 --- a/test/SmokeTests/e2e-test.ps1 +++ b/test/SmokeTests/e2e-test.ps1 @@ -31,7 +31,7 @@ $AzuriteVersion = "3.32.0" if ($NoSetup -eq $false) { # Build the docker image first, since that's the most critical step Write-Host "Building sample app Docker container from '$DockerfilePath'..." -ForegroundColor Yellow - docker build -f $DockerfilePath -t $ImageName --progress plain $PSScriptRoot/../../ + docker build --pull -f $DockerfilePath -t $ImageName --progress plain $PSScriptRoot/../../ Exit-OnError # Next, download and start the Azurite emulator Docker image @@ -58,6 +58,16 @@ if ($NoSetup -eq $false) { Start-Sleep -Seconds 30 # Adjust the sleep duration based on your SQL Server container startup time Exit-OnError + Write-Host "Checking if SQL Server is still running..." -ForegroundColor Yellow + $sqlServerStatus = docker inspect -f '{{.State.Status}}' mssql-server + Exit-OnError + + if ($sqlServerStatus -ne "running") { + Write-Host "Unexpected SQL Server status: $sqlServerStatus" -ForegroundColor Yellow + docker logs mssql-server + exit 1; + } + # Get SQL Server IP Address - used to create SQLDB_Connection Write-Host "Getting IP Address..." -ForegroundColor Yellow $serverIpAddress = docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mssql-server diff --git a/test/Worker.Extensions.DurableTask.Tests/FunctionsDurableTaskClientTests.cs b/test/Worker.Extensions.DurableTask.Tests/FunctionsDurableTaskClientTests.cs index 5a335aefa..1623f4559 100644 --- a/test/Worker.Extensions.DurableTask.Tests/FunctionsDurableTaskClientTests.cs +++ b/test/Worker.Extensions.DurableTask.Tests/FunctionsDurableTaskClientTests.cs @@ -1,6 +1,10 @@ +using System.Net; +using Azure.Core.Serialization; +using Microsoft.Azure.Functions.Worker.Http; using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.Grpc; +using Microsoft.Extensions.Options; using Moq; +using Newtonsoft.Json; namespace Microsoft.Azure.Functions.Worker.Tests { @@ -9,7 +13,7 @@ namespace Microsoft.Azure.Functions.Worker.Tests /// public class FunctionsDurableTaskClientTests { - private FunctionsDurableTaskClient GetTestFunctionsDurableTaskClient() + private FunctionsDurableTaskClient GetTestFunctionsDurableTaskClient(string? baseUrl = null, OrchestrationMetadata? orchestrationMetadata = null) { // construct mock client @@ -21,8 +25,14 @@ private FunctionsDurableTaskClient GetTestFunctionsDurableTaskClient() durableClientMock.Setup(x => x.TerminateInstanceAsync( It.IsAny(), It.IsAny(), It.IsAny())).Returns(completedTask); + if (orchestrationMetadata != null) + { + durableClientMock.Setup(x => x.GetInstancesAsync(orchestrationMetadata.InstanceId, It.IsAny(), It.IsAny())) + .ReturnsAsync(orchestrationMetadata); + } + DurableTaskClient durableClient = durableClientMock.Object; - FunctionsDurableTaskClient client = new FunctionsDurableTaskClient(durableClient, queryString: null); + FunctionsDurableTaskClient client = new FunctionsDurableTaskClient(durableClient, queryString: null, httpBaseUrl: baseUrl); return client; } @@ -53,5 +63,274 @@ public async void TerminateDoesNotThrow() await client.TerminateInstanceAsync(instanceId, options); await client.TerminateInstanceAsync(instanceId, options, token); } + + /// + /// Test that the `CreateHttpManagementPayload` method returns the expected payload structure without HttpRequestData. + /// + [Fact] + public void CreateHttpManagementPayload_WithBaseUrl() + { + const string BaseUrl = "http://localhost:7071/runtime/webhooks/durabletask"; + FunctionsDurableTaskClient client = this.GetTestFunctionsDurableTaskClient(BaseUrl); + string instanceId = "testInstanceIdWithHostBaseUrl"; + + HttpManagementPayload payload = client.CreateHttpManagementPayload(instanceId); + + AssertHttpManagementPayload(payload, BaseUrl, instanceId); + } + + /// + /// Test that the `CreateHttpManagementPayload` method returns the expected payload structure with HttpRequestData. + /// + [Fact] + public void CreateHttpManagementPayload_WithHttpRequestData() + { + const string requestUrl = "http://localhost:7075/orchestrators/E1_HelloSequence"; + FunctionsDurableTaskClient client = this.GetTestFunctionsDurableTaskClient(); + string instanceId = "testInstanceIdWithRequest"; + + // Create mock HttpRequestData object. + var mockFunctionContext = new Mock(); + var mockHttpRequestData = new Mock(mockFunctionContext.Object); + var headers = new HttpHeadersCollection(); + mockHttpRequestData.SetupGet(r => r.Headers).Returns(headers); + mockHttpRequestData.SetupGet(r => r.Url).Returns(new Uri(requestUrl)); + + HttpManagementPayload payload = client.CreateHttpManagementPayload(instanceId, mockHttpRequestData.Object); + + AssertHttpManagementPayload(payload, "http://localhost:7075/runtime/webhooks/durabletask", instanceId); + } + + /// + /// Test that the `WaitForCompletionOrCreateCheckStatusResponseAsync` method returns the expected response when the orchestration is completed. + /// The expected response should include OrchestrationMetadata in the body with an HttpStatusCode.OK. + /// + [Fact] + public async Task TestWaitForCompletionOrCreateCheckStatusResponseAsync_WhenCompleted() + { + string instanceId = "test-instance-id-completed"; + var expectedResult = new OrchestrationMetadata("TestCompleted", instanceId) + { + CreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + RuntimeStatus = OrchestrationRuntimeStatus.Completed, + SerializedCustomStatus = "TestCustomStatus", + SerializedInput = "TestInput", + SerializedOutput = "TestOutput" + }; + + var client = this.GetTestFunctionsDurableTaskClient( orchestrationMetadata: expectedResult); + + HttpRequestData request = this.MockHttpRequestAndResponseData(); + + HttpResponseData response = await client.WaitForCompletionOrCreateCheckStatusResponseAsync(request, instanceId); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Reset stream position for reading + response.Body.Position = 0; + var orchestratorMetadata = await System.Text.Json.JsonSerializer.DeserializeAsync(response.Body); + + // Assert the response content is not null and check the content is correct. + Assert.NotNull(orchestratorMetadata); + AssertOrhcestrationMetadata(expectedResult, orchestratorMetadata); + } + + /// + /// Test that the `WaitForCompletionOrCreateCheckStatusResponseAsync` method returns expected response when the orchestrator didn't finish within + /// the timeout period. The response body should contain a HttpManagementPayload with HttpStatusCode.Accepted. + /// + [Fact] + public async Task TestWaitForCompletionOrCreateCheckStatusResponseAsync_WhenRunning() + { + string instanceId = "test-instance-id-running"; + var expectedResult = new OrchestrationMetadata("TestRunning", instanceId) + { + CreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + RuntimeStatus = OrchestrationRuntimeStatus.Running, + }; + + var client = this.GetTestFunctionsDurableTaskClient(orchestrationMetadata: expectedResult); + + HttpRequestData request = this.MockHttpRequestAndResponseData(); + HttpResponseData response; + using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))) + { + response = await client.WaitForCompletionOrCreateCheckStatusResponseAsync(request, instanceId, cancellation: cts.Token); + }; + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + // Reset stream position for reading + response.Body.Position = 0; + HttpManagementPayload? payload; + using (var reader = new StreamReader(response.Body)) + { + payload = JsonConvert.DeserializeObject(await reader.ReadToEndAsync()); + } + + // Assert the response content is not null and check the content is correct. + Assert.NotNull(payload); + AssertHttpManagementPayload(payload, "https://localhost:7075/runtime/webhooks/durabletask", instanceId); + } + + /// + /// Tests the `WaitForCompletionOrCreateCheckStatusResponseAsync` method to ensure it returns the correct HTTP status code + /// based on the `returnInternalServerErrorOnFailure` parameter when the orchestration has failed. + /// + [Theory] + [InlineData(true, HttpStatusCode.InternalServerError)] + [InlineData(false, HttpStatusCode.OK)] + public async Task TestWaitForCompletionOrCreateCheckStatusResponseAsync_WhenFailed(bool returnInternalServerErrorOnFailure, HttpStatusCode expected) + { + string instanceId = "test-instance-id-failed"; + var expectedResult = new OrchestrationMetadata("TestFailed", instanceId) + { + CreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + RuntimeStatus = OrchestrationRuntimeStatus.Failed, + SerializedOutput = "Microsoft.DurableTask.TaskFailedException: Task 'SayHello' (#0) failed with an unhandled exception: Exception while executing function: Functions.SayHello", + SerializedInput = null + }; + + var client = this.GetTestFunctionsDurableTaskClient(orchestrationMetadata: expectedResult); + + HttpRequestData request = this.MockHttpRequestAndResponseData(); + + HttpResponseData response = await client.WaitForCompletionOrCreateCheckStatusResponseAsync(request, instanceId, returnInternalServerErrorOnFailure: returnInternalServerErrorOnFailure); + + Assert.NotNull(response); + Assert.Equal(expected, response.StatusCode); + + // Reset stream position for reading + response.Body.Position = 0; + var orchestratorMetadata = await System.Text.Json.JsonSerializer.DeserializeAsync(response.Body); + + // Assert the response content is not null and check the content is correct. + Assert.NotNull(orchestratorMetadata); + AssertOrhcestrationMetadata(expectedResult, orchestratorMetadata); + } + + /// + /// Tests the `GetBaseUrlFromRequest` can return the right base URL from the HttpRequestData with different forwarding or proxies. + /// This test covers the following scenarios: + /// - Using the "Forwarded" header + /// - Using "X-Forwarded-Proto" and "X-Forwarded-Host" headers + /// - Using only "X-Forwarded-Host" with default protocol + /// - no headers + /// + [Theory] + [InlineData("Forwarded", "proto=https;host=forwarded.example.com","","", "https://forwarded.example.com/runtime/webhooks/durabletask")] + [InlineData("X-Forwarded-Proto", "https", "X-Forwarded-Host", "xforwarded.example.com", "https://xforwarded.example.com/runtime/webhooks/durabletask")] + [InlineData("", "", "X-Forwarded-Host", "test.net", "https://test.net/runtime/webhooks/durabletask")] + [InlineData("", "", "", "", "https://localhost:7075/runtime/webhooks/durabletask")] // Default base URL for empty headers + public void TestHttpRequestDataForwardingHandling(string header1, string? value1, string header2, string value2, string expectedBaseUrl) + { + var headers = new HttpHeadersCollection(); + if (!string.IsNullOrEmpty(header1)) + { + headers.Add(header1, value1); + } + if (!string.IsNullOrEmpty(header2)) + { + headers.Add(header2, value2); + } + + var request = this.MockHttpRequestAndResponseData(headers); + var client = this.GetTestFunctionsDurableTaskClient(); + + var payload = client.CreateHttpManagementPayload("testInstanceId", request); + AssertHttpManagementPayload(payload, expectedBaseUrl, "testInstanceId"); + } + + + + private static void AssertHttpManagementPayload(HttpManagementPayload payload, string BaseUrl, string instanceId) + { + Assert.Equal(instanceId, payload.Id); + Assert.Equal($"{BaseUrl}/instances/{instanceId}", payload.PurgeHistoryDeleteUri); + Assert.Equal($"{BaseUrl}/instances/{instanceId}/raiseEvent/{{eventName}}", payload.SendEventPostUri); + Assert.Equal($"{BaseUrl}/instances/{instanceId}", payload.StatusQueryGetUri); + Assert.Equal($"{BaseUrl}/instances/{instanceId}/terminate?reason={{{{text}}}}", payload.TerminatePostUri); + Assert.Equal($"{BaseUrl}/instances/{instanceId}/suspend?reason={{{{text}}}}", payload.SuspendPostUri); + Assert.Equal($"{BaseUrl}/instances/{instanceId}/resume?reason={{{{text}}}}", payload.ResumePostUri); + } + + private static void AssertOrhcestrationMetadata(OrchestrationMetadata expectedResult, dynamic actualResult) + { + Assert.Equal(expectedResult.Name, actualResult.GetProperty("Name").GetString()); + Assert.Equal(expectedResult.InstanceId, actualResult.GetProperty("InstanceId").GetString()); + Assert.Equal(expectedResult.CreatedAt, actualResult.GetProperty("CreatedAt").GetDateTime()); + Assert.Equal(expectedResult.LastUpdatedAt, actualResult.GetProperty("LastUpdatedAt").GetDateTime()); + Assert.Equal(expectedResult.RuntimeStatus.ToString(), actualResult.GetProperty("RuntimeStatus").GetString()); + Assert.Equal(expectedResult.SerializedInput, actualResult.GetProperty("SerializedInput").GetString()); + Assert.Equal(expectedResult.SerializedOutput, actualResult.GetProperty("SerializedOutput").GetString()); + Assert.Equal(expectedResult.SerializedCustomStatus, actualResult.GetProperty("SerializedCustomStatus").GetString()); + } + + // Mocks the required HttpRequestData and HttpResponseData for testing purposes. + // This method sets up a mock HttpRequestData with a predefined URL and a mock HttpResponseDatav with a default status code and body. + // The headers of HttpRequestData can be provided as an optional parameter, otherwise an empty HttpHeadersCollection is used. + private HttpRequestData MockHttpRequestAndResponseData(HttpHeadersCollection? headers = null) + { + var mockObjectSerializer = new Mock(); + + // Setup the SerializeAsync method + mockObjectSerializer.Setup(s => s.SerializeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (stream, value, type, token) => + { + await System.Text.Json.JsonSerializer.SerializeAsync(stream, value, type, cancellationToken: token); + }); + + var workerOptions = new WorkerOptions + { + Serializer = mockObjectSerializer.Object + }; + var mockOptions = new Mock>(); + mockOptions.Setup(o => o.Value).Returns(workerOptions); + + // Mock the service provider + var mockServiceProvider = new Mock(); + + // Set up the service provider to return the mock IOptions + mockServiceProvider.Setup(sp => sp.GetService(typeof(IOptions))) + .Returns(mockOptions.Object); + + // Set up the service provider to return the mock ObjectSerializer + mockServiceProvider.Setup(sp => sp.GetService(typeof(ObjectSerializer))) + .Returns(mockObjectSerializer.Object); + + // Create a mock FunctionContext and assign the service provider + var mockFunctionContext = new Mock(); + mockFunctionContext.SetupGet(c => c.InstanceServices).Returns(mockServiceProvider.Object); + var mockHttpRequestData = new Mock(mockFunctionContext.Object); + + // Set up the URL property. + mockHttpRequestData.SetupGet(r => r.Url).Returns(new Uri("https://localhost:7075/orchestrators/E1_HelloSequence")); + + // If headers are provided, use them, otherwise create a new empty HttpHeadersCollection + headers ??= new HttpHeadersCollection(); + + // Setup the Headers property to return the empty headers + mockHttpRequestData.SetupGet(r => r.Headers).Returns(headers); + + var mockHttpResponseData = new Mock(mockFunctionContext.Object) + { + DefaultValue = DefaultValue.Mock + }; + + // Enable setting StatusCode and Body as mutable properties + mockHttpResponseData.SetupProperty(r => r.StatusCode, HttpStatusCode.OK); + mockHttpResponseData.SetupProperty(r => r.Body, new MemoryStream()); + + // Setup CreateResponse to return the configured HttpResponseData mock + mockHttpRequestData.Setup(r => r.CreateResponse()) + .Returns(mockHttpResponseData.Object); + + return mockHttpRequestData.Object; + } } -} \ No newline at end of file +}