├── global.json ├── img └── storm-icon.png ├── codecov.yml ├── charts ├── azurite │ ├── Chart.yaml │ ├── .helmignore │ ├── README.md │ ├── templates │ │ ├── 02-service.yaml │ │ ├── _helpers.tpl │ │ └── 01-deployment.yaml │ └── values.yaml ├── example-function-app │ ├── Chart.yaml │ ├── README.md │ ├── templates │ │ ├── 01-serviceaccount.yaml │ │ ├── 03-triggerauthentication.yaml │ │ ├── 04-scaledobject.yaml │ │ ├── _helpers.tpl │ │ └── 02-deployment.yaml │ ├── .helmignore │ └── values.yaml └── durabletask-azurestorage-scaler │ ├── .helmignore │ ├── templates │ ├── 03-service.yaml │ ├── 01-serviceaccount.yaml │ └── _helpers.tpl │ └── Chart.yaml ├── .gitattributes ├── tests ├── Keda.Scaler.Functions.Worker.DurableTask.Examples │ ├── local.settings.json │ ├── Program.cs │ ├── Properties │ │ ├── serviceDependencies.json │ │ ├── serviceDependencies.local.json │ │ └── launchSettings.json │ ├── host.json │ ├── ScaleTestInput.cs │ ├── Keda.Scaler.Functions.Worker.DurableTask.Examples.csproj │ └── Dockerfile ├── Keda.Scaler.DurableTask.AzureStorage.Test.Integration │ ├── K8s │ │ ├── FunctionDeploymentOptions.cs │ │ └── KubernetesOptions.cs │ ├── AzureStorageDurableTaskClientOptions.cs │ ├── Keda.Scaler.DurableTask.AzureStorage.Test.Integration.csproj │ ├── ScaleTestOptions.cs │ └── Log.cs └── ScaleTests.sln ├── NuGet.config ├── src ├── Keda.Scaler.DurableTask.AzureStorage │ ├── Metadata │ │ ├── ScalerOptions.Validate.cs │ │ ├── ScalerMetadataAccessor.cs │ │ ├── IScalerMetadataAccessor.cs │ │ ├── IServiceCollection.Extensions.cs │ │ └── ConfigureScalerOptions.cs │ ├── LogCategories.cs │ ├── Certificates │ │ ├── CaCertificateFileOptions.cs │ │ ├── ClientCertificateValidationOptions.Validate.cs │ │ ├── ConfigureCustomTrustStore.Log.cs │ │ ├── ClientCertificateValidationOptions.cs │ │ ├── CaCertificateReaderMiddleware.cs │ │ ├── IConfiguration.Extensions.cs │ │ ├── IServiceCollection.Extensions.cs │ │ └── ConfigureCustomTrustStore.cs │ ├── TaskHubs │ │ ├── PartitionsTable.cs │ │ ├── WorkItemQueue.cs │ │ ├── LeasesContainer.cs │ │ ├── TaskHubOptions.cs │ │ ├── TablePartitionManager.Log.cs │ │ ├── ControlQueue.cs │ │ ├── BlobPartitionManager.Log.cs │ │ ├── ConfigureTaskHubOptions.cs │ │ ├── TaskHub.Log.cs │ │ ├── ITaskHub.cs │ │ ├── ITaskHubPartitionManager.cs │ │ ├── IServiceCollection.Extensions.cs │ │ ├── DurableTaskScaleManager.Log.cs │ │ ├── TablePartitionManager.cs │ │ ├── TaskHubQueueUsage.cs │ │ ├── BlobPartitionManager.cs │ │ └── TaskHub.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Json │ │ └── SourceGenerationContext.cs │ ├── Clients │ │ ├── AzureStorageService.cs │ │ ├── BlobServiceClientFactory.cs │ │ ├── QueueServiceClientFactory.cs │ │ ├── TableServiceClientFactory.cs │ │ ├── CloudEnvironment.cs │ │ ├── ValidateAzureStorageAccountOptions.cs │ │ ├── AzureStorageServiceUri.cs │ │ ├── AzureStorageAccountOptions.cs │ │ ├── IServiceCollection.Extensions.cs │ │ ├── ConfigureAzureStorageAccountOptions.cs │ │ └── AzureStorageAccountClientFactory.cs │ ├── appsettings.json │ ├── Gen │ │ └── System.Text.Json.SourceGeneration │ │ │ └── System.Text.Json.SourceGeneration.JsonSourceGenerator │ │ │ ├── SourceGenerationContext.PropertyNames.g.cs │ │ │ ├── SourceGenerationContext.Int32.g.cs │ │ │ ├── SourceGenerationContext.GetJsonTypeInfo.g.cs │ │ │ ├── SourceGenerationContext.String.g.cs │ │ │ └── SourceGenerationContext.DateTimeOffset.g.cs │ ├── DataAnnotations │ │ └── FileExistsAttribute.cs │ ├── Interceptors │ │ ├── ExceptionInterceptor.Log.cs │ │ ├── ScalerMetadataInterceptor.cs │ │ └── ExceptionInterceptor.cs │ ├── Protos │ │ └── externalscaler.proto │ ├── SR.Formats.cs │ ├── Dockerfile │ ├── Keda.Scaler.DurableTask.AzureStorage.csproj │ └── Program.cs ├── Keda.Scaler.DurableTask.AzureStorage.Test │ ├── Certificates │ │ ├── CertificateTestCollection.cs │ │ └── RSA.Extensions.cs │ ├── Keda.Scaler.DurableTask.AzureStorage.Test.csproj │ ├── Metadata │ │ ├── ScalerMetadataAccessor.Test.cs │ │ ├── IServiceCollection.Extensions.Test.cs │ │ └── ConfigureScalerOptions.Test.cs │ ├── TaskHubs │ │ ├── WorkItemQueue.Test.cs │ │ ├── LeasesContainer.Test.cs │ │ ├── PartitionsTable.Test.cs │ │ ├── ControlQueue.Test.cs │ │ ├── TaskHubQueueUsage.Test.cs │ │ ├── OptimalDurableTaskScaleManager.Test.cs │ │ └── ConfigureTaskHubOptions.Test.cs │ ├── TestEnvironment.cs │ ├── Clients │ │ ├── BlobServiceClientFactory.Test.cs │ │ ├── QueueServiceClientFactory.Test.cs │ │ ├── AzureStorageServiceUri.Test.cs │ │ ├── TableQueueServiceClientFactory.Test.cs │ │ ├── AzureStorageAccountClientFactory.Test.cs │ │ └── ValidateAzureStorageAccountOptions.Test.cs │ ├── MockServerCallContext.cs │ ├── DataAnnotations │ │ └── FileExistsAttribute.Test.cs │ └── Interceptors │ │ └── ExceptionInterceptor.Test.cs ├── Directory.Build.props ├── Scaler.sln └── CodeCoverage.runsettings ├── .dockerignore ├── SECURITY.md ├── .editorconfig ├── .github ├── workflows │ ├── codeql-analysis.yml │ ├── scaler-pr.yml │ └── scaler-ci-image.yml └── actions │ ├── code-coverage │ └── action.yml │ ├── parse-chart │ ├── action.yml │ └── scripts │ │ └── ParseVersions.ps1 │ ├── github-release │ ├── action.yml │ └── scripts │ │ └── CreateRelease.mjs │ ├── helm-package │ └── action.yml │ └── docker-build │ └── action.yml ├── .gitignore ├── LICENSE ├── renovate.json ├── Directory.Build.props └── Directory.Packages.props /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.308" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /img/storm-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wsugarman/durabletask-azurestorage-scaler/HEAD/img/storm-icon.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 100% 6 | threshold: 0% 7 | -------------------------------------------------------------------------------- /charts/azurite/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | description: The Azure Storage emulator used for testing 3 | name: azurite 4 | type: application 5 | version: '1.0.0' 6 | -------------------------------------------------------------------------------- /charts/example-function-app/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | description: An example function app used for integration testing. 3 | name: example-function-app 4 | type: application 5 | version: '1.0.0' 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto 3 | 4 | # Unix-specific 5 | *.sh text eol=lf 6 | 7 | # Windows-specific 8 | *.cmd text eol=crlf 9 | *.bat text eol=crlf 10 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsSecretStorageType": "files", 5 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 6 | "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /charts/example-function-app/README.md: -------------------------------------------------------------------------------- 1 | # example-function-app 2 | This helm chart is only used for integration testing, and therefore is not published. 3 | 4 | To learn more about running Azure Functions on Kubernetes, see https://learn.microsoft.com/en-us/azure/azure-functions/functions-kubernetes-keda. 5 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Hosting; 5 | 6 | IHost host = new HostBuilder() 7 | .ConfigureFunctionsWorkerDefaults() 8 | .Build(); 9 | 10 | host.Run(); 11 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/Properties/serviceDependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights" 5 | }, 6 | "storage1": { 7 | "type": "storage", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/Properties/serviceDependencies.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights.sdk" 5 | }, 6 | "storage1": { 7 | "type": "storage.emulator", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | }, 9 | "enableLiveMetricsFilters": true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /charts/example-function-app/templates/01-serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: {{ template "example-function-app.fullname" . }} 6 | {{- include "example-function-app.labels" . | indent 4 }} 7 | name: {{ template "example-function-app.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Metadata/ScalerOptions.Validate.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Metadata; 7 | 8 | [OptionsValidator] 9 | internal partial class ValidateTaskHubScalerOptions : IValidateOptions 10 | { } 11 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/LogCategories.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | namespace Keda.Scaler.DurableTask.AzureStorage; 5 | 6 | internal static class LogCategories 7 | { 8 | // Reuse prefix from the Durable Task framework 9 | public const string Default = "DurableTask.AzureStorage.Keda"; 10 | 11 | public const string Security = Default + ".Security"; 12 | } 13 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Keda.Scaler.Functions.Worker.DurableTask.Examples": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--port 7115" 6 | }, 7 | "Docker": { 8 | "commandName": "Docker", 9 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 10 | "httpPort": 33405, 11 | "useSSL": false 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /charts/azurite/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/example-function-app/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/durabletask-azurestorage-scaler/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/appsettings.Development.json 15 | **/azds.yaml 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/Dockerfile* 20 | **/local.settings.json 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | LICENSE 27 | README.md 28 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/CaCertificateFileOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.ComponentModel.DataAnnotations; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 7 | 8 | internal class CaCertificateFileOptions 9 | { 10 | [Required] 11 | public string Path { get; set; } = default!; 12 | 13 | [Range(0, 1000 * 60)] 14 | public int ReloadDelayMs { get; set; } = 250; 15 | } 16 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/ClientCertificateValidationOptions.Validate.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 7 | 8 | [OptionsValidator] 9 | internal partial class ValidateClientCertificateValidationOptions : IValidateOptions 10 | { 11 | public static ValidateClientCertificateValidationOptions Instance { get; } = new(); 12 | } 13 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Certificates/CertificateTestCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Xunit; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Certificates; 7 | 8 | [CollectionDefinition(nameof(CertificateTestCollection), DisableParallelization = true)] 9 | [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Suffix used for test collection.")] 10 | public class CertificateTestCollection 11 | { } 12 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Keda.Scaler.DurableTask.AzureStorage.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /charts/durabletask-azurestorage-scaler/templates/03-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: {{ template "durabletask-azurestorage-scaler.fullname" . }} 6 | {{- include "durabletask-azurestorage-scaler.labels" . | indent 4 }} 7 | name: {{ template "durabletask-azurestorage-scaler.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | spec: 10 | ports: 11 | - name: scaler 12 | port: {{ .Values.port }} 13 | targetPort: 8080 14 | selector: 15 | app: {{ template "durabletask-azurestorage-scaler.fullname" . }} 16 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/PartitionsTable.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | 8 | internal static class PartitionsTable 9 | { 10 | // See: https://learn.microsoft.com/en-us/rest/api/storageservices/understanding-the-table-service-data-model#table-names 11 | public static string GetName(string taskHub) 12 | { 13 | ArgumentException.ThrowIfNullOrWhiteSpace(taskHub); 14 | return taskHub + "Partitions"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Keda.Scaler.DurableTask.AzureStorage": { 4 | "commandName": "Project", 5 | "environmentVariables": { 6 | "ASPNETCORE_ENVIRONMENT": "Development" 7 | }, 8 | "applicationUrl": "http://localhost:5255;https://localhost:7255", 9 | "dotnetRunMessages": true 10 | }, 11 | "Docker": { 12 | "commandName": "Docker", 13 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 14 | "publishAllPorts": true, 15 | "useSSL": true 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/ConfigureCustomTrustStore.Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 7 | 8 | internal static partial class Log 9 | { 10 | [LoggerMessage( 11 | EventId = 13, 12 | Level = LogLevel.Information, 13 | Message = "The custom CA certificate at '{Path}' has been reloaded with thumbprint {Thumbprint}.")] 14 | public static partial void ReloadedCustomCertificateAuthority(this ILogger logger, string path, string thumbprint); 15 | } 16 | -------------------------------------------------------------------------------- /charts/azurite/README.md: -------------------------------------------------------------------------------- 1 | # azurite 2 | This helm chart is only used for integration testing, and therefore is not published. Its design is based on https://github.com/Azure/Azurite/issues/968#issuecomment-887974975. 3 | 4 | If you wish to connect to the emulator (e.g. via the [Microsoft Azure Storage Explorer](https://azure.microsoft.com/en-us/products/storage/storage-explorer/)), run the following command in a separate window: 5 | ```bash 6 | kubectl port-forward service/azurite -n azure 10000:10000 10001:10001 10002:10002 7 | ``` 8 | 9 | To learn more about the Azurite emulator, see https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite. 10 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Metadata/ScalerMetadataAccessor.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Collections.Generic; 5 | using System.Threading; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Metadata; 8 | 9 | internal sealed class ScalerMetadataAccessor : IScalerMetadataAccessor 10 | { 11 | private IReadOnlyDictionary? _scalerMetadata; 12 | 13 | /// 14 | public IReadOnlyDictionary? ScalerMetadata 15 | { 16 | get => Volatile.Read(ref _scalerMetadata); 17 | set => Volatile.Write(ref _scalerMetadata, value); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /charts/azurite/templates/02-service.yaml: -------------------------------------------------------------------------------- 1 | kind: Service 2 | apiVersion: v1 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: {{ template "azurite.fullname" . }} 6 | {{- include "azurite.labels" . | indent 4 }} 7 | name: {{ template "azurite.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | spec: 10 | selector: 11 | app: {{ template "azurite.fullname" . }} 12 | ports: 13 | - name: blob 14 | protocol: TCP 15 | port: 10000 16 | targetPort: 10000 17 | - name: queue 18 | protocol: TCP 19 | port: 10001 20 | targetPort: 10001 21 | - name: table 22 | protocol: TCP 23 | port: 10002 24 | targetPort: 10002 25 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Metadata/IScalerMetadataAccessor.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Collections.Generic; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Metadata; 7 | 8 | /// 9 | /// Provides access to the current requests metadata, if any is available. 10 | /// 11 | public interface IScalerMetadataAccessor 12 | { 13 | /// 14 | /// Gets or sets the current scaler metadata. 15 | /// 16 | /// 17 | /// The current metadata if available; otherwise, . 18 | /// 19 | IReadOnlyDictionary? ScalerMetadata { get; set; } 20 | } 21 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Json/SourceGenerationContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Text.Json; 5 | using System.Text.Json.Serialization; 6 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Json; 9 | 10 | [JsonSourceGenerationOptions( 11 | GenerationMode = JsonSourceGenerationMode.Default, 12 | PropertyNameCaseInsensitive = true, 13 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, 14 | ReadCommentHandling = JsonCommentHandling.Skip)] 15 | [JsonSerializable(typeof(BlobPartitionManager.AzureStorageTaskHubInfo))] 16 | internal partial class SourceGenerationContext : JsonSerializerContext 17 | { } 18 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/WorkItemQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 8 | 9 | internal static class WorkItemQueue 10 | { 11 | // See: https://learn.microsoft.com/en-us/rest/api/storageservices/naming-queues-and-metadata#queue-names 12 | [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Queue names must be lowercase.")] 13 | public static string GetName(string taskHub) 14 | { 15 | ArgumentException.ThrowIfNullOrWhiteSpace(taskHub); 16 | return taskHub.ToLowerInvariant() + "-workitems"; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.DurableTask.AzureStorage.Test.Integration/K8s/FunctionDeploymentOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.ComponentModel.DataAnnotations; 5 | using System.Diagnostics.CodeAnalysis; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Integration.K8s; 8 | 9 | [SuppressMessage("Microsoft.Performance", "CA1812:Avoid uninstantiated internal classes.", Justification = "This class is instantiated via dependency injection.")] 10 | internal sealed class FunctionDeploymentOptions 11 | { 12 | public const string DefaultSectionName = "Function"; 13 | 14 | [Required] 15 | public string? Name { get; set; } 16 | 17 | [Required] 18 | public string? Namespace { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/AzureStorageService.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 5 | 6 | /// 7 | /// Represents a data service included in Azure Storage. 8 | /// 9 | public enum AzureStorageService 10 | { 11 | /// 12 | /// A massively scalable object store for text and binary data. 13 | /// 14 | Blob, 15 | 16 | /// 17 | /// A messaging store for reliable messaging between application components. 18 | /// 19 | Queue, 20 | 21 | /// 22 | /// A NoSQL store for schemaless storage of structured data. 23 | /// 24 | Table, 25 | } 26 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/BlobServiceClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Azure.Core; 5 | using Azure.Storage.Blobs; 6 | using System; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 9 | 10 | internal sealed class BlobServiceClientFactory : AzureStorageAccountClientFactory 11 | { 12 | protected override AzureStorageService Service => AzureStorageService.Blob; 13 | 14 | protected override BlobServiceClient CreateServiceClient(string connectionString) 15 | => new(connectionString); 16 | 17 | protected override BlobServiceClient CreateServiceClient(Uri serviceUri, TokenCredential credential) 18 | => new(serviceUri, credential); 19 | } 20 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*", 3 | "Kestrel": { 4 | "EndpointDefaults": { 5 | "Protocols": "Http2" 6 | } 7 | }, 8 | "Logging": { 9 | "LogLevel": { 10 | "Default": "Information" 11 | }, 12 | "Console": { 13 | "FormatterName": "systemd", 14 | "FormatterOptions": { 15 | "IncludeScopes": false, 16 | "SingleLine": true, 17 | "TimestampFormat": "O", 18 | "UseUtcTimestamp": true 19 | } 20 | } 21 | }, 22 | "Security": { 23 | "Transport": { 24 | "Client": { 25 | "Authentication": { 26 | "Caching": { 27 | "CacheEntryExpiration": "00:05:00" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Unless otherwise noted, security issues are fixed in the latest version of the scaler and 6 | are **not** cherry-picked into previous versions via a patch. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | `1.x` | :x: | 11 | | `2.0.x` | :x: | 12 | | `2.1.x` | :heavy_check_mark: | 13 | 14 | ## Reporting a Vulnerability 15 | 16 | While this repository proactively monitors for security vulnerabilities, 17 | please file an [issue](https://github.com/wsugarman/durabletask-azurestorage-scaler/issues) with the `security` tag 18 | if you detect a security-related issue. Its risk will be assessed, and maintainers will respond with next steps in a timely fashion. 19 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/QueueServiceClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Azure.Core; 5 | using Azure.Storage.Queues; 6 | using System; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 9 | 10 | internal sealed class QueueServiceClientFactory : AzureStorageAccountClientFactory 11 | { 12 | protected override AzureStorageService Service => AzureStorageService.Queue; 13 | 14 | protected override QueueServiceClient CreateServiceClient(string connectionString) 15 | => new(connectionString); 16 | 17 | protected override QueueServiceClient CreateServiceClient(Uri serviceUri, TokenCredential credential) 18 | => new(serviceUri, credential); 19 | } 20 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/TableServiceClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Azure.Core; 5 | using Azure.Data.Tables; 6 | using System; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 9 | 10 | internal sealed class TableServiceClientFactory : AzureStorageAccountClientFactory 11 | { 12 | protected override AzureStorageService Service => AzureStorageService.Table; 13 | 14 | protected override TableServiceClient CreateServiceClient(string connectionString) 15 | => new(connectionString); 16 | 17 | protected override TableServiceClient CreateServiceClient(Uri serviceUri, TokenCredential credential) 18 | => new(serviceUri, credential); 19 | } 20 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/LeasesContainer.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 8 | 9 | internal static class LeasesContainer 10 | { 11 | public const string TaskHubBlobName = "taskhub.json"; 12 | 13 | // See: https://learn.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#container-names 14 | [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Blob container names must be lowercase.")] 15 | public static string GetName(string taskHub) 16 | { 17 | ArgumentException.ThrowIfNullOrWhiteSpace(taskHub); 18 | return taskHub.ToLowerInvariant() + "-leases"; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/ScaleTestInput.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | 6 | namespace Keda.Scaler.Functions.Worker.DurableTask.Examples; 7 | 8 | /// 9 | /// Represents the input for a scale test orchestration. 10 | /// 11 | public sealed class ScaleTestInput 12 | { 13 | /// 14 | /// Gets or sets the number of activities to be created by the orchestration. 15 | /// 16 | /// The non-negativ number of activities. 17 | public int ActivityCount { get; set; } 18 | 19 | /// 20 | /// Gets or sets the amount of time each activity should take to execute. 21 | /// 22 | /// The duration of each activity. 23 | public TimeSpan ActivityTime { get; set; } 24 | } 25 | -------------------------------------------------------------------------------- /charts/example-function-app/templates/03-triggerauthentication.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: keda.sh/v1alpha1 2 | kind: TriggerAuthentication 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: {{ template "example-function-app.fullname" . }} 6 | {{- include "example-function-app.labels" . | indent 4 }} 7 | name: {{ template "example-function-app.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | spec: 10 | secretTargetRef: 11 | {{- if .Values.scaledObject.caCertSecret }} 12 | - parameter: caCert 13 | name: {{ .Values.scaledObject.caCertSecret }} 14 | key: tls.crt 15 | {{- end }} 16 | {{- if .Values.scaledObject.tlsClientCertSecret }} 17 | - parameter: tlsClientCert 18 | name: {{ .Values.scaledObject.tlsClientCertSecret }} 19 | key: tls.crt 20 | - parameter: tlsClientKey 21 | name: {{ .Values.scaledObject.tlsClientCertSecret }} 22 | key: tls.key 23 | {{- end }} 24 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.DurableTask.AzureStorage.Test.Integration/K8s/KubernetesOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.ComponentModel.DataAnnotations; 5 | using System.Diagnostics.CodeAnalysis; 6 | using k8s; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Integration.K8s; 9 | 10 | [SuppressMessage("Microsoft.Performance", "CA1812:Avoid uninstantiated internal classes.", Justification = "This class is instantiated via dependency injection.")] 11 | internal sealed class KubernetesOptions 12 | { 13 | public const string DefaultSectionName = "Kubernetes"; 14 | 15 | public string? ConfigPath { get; set; } 16 | 17 | [Required] 18 | public string? Context { get; set; } 19 | 20 | public KubernetesClientConfiguration ToClientConfiguration() 21 | => KubernetesClientConfiguration.BuildConfigFromConfigFile(ConfigPath, Context); 22 | } 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | # Top-most EditorConfig file 4 | root = true 5 | 6 | # Default settings: 7 | # - UTF-8 8 | # - Posix-style newline ending every file 9 | # - Use 4 spaces as indentation 10 | [*] 11 | charset = utf-8 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 4 15 | 16 | # Xml project files 17 | [*.{csproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] 18 | indent_size = 2 19 | 20 | # Xml build files 21 | [*.builds] 22 | indent_size = 2 23 | 24 | # Xml files 25 | [*.{xml,stylecop,resx,ruleset}] 26 | indent_size = 2 27 | 28 | # Xml config files 29 | [*.{props,targets,config,nuspec,runsettings}] 30 | indent_size = 2 31 | 32 | # JSON files 33 | [*.json] 34 | indent_size = 2 35 | 36 | # YAML files 37 | [*.{yml,yaml}] 38 | indent_size = 2 39 | 40 | # Shell scripts 41 | [*.sh] 42 | end_of_line = lf 43 | 44 | [*.{cmd,bat}] 45 | end_of_line = crlf 46 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Metadata/ScalerMetadataAccessor.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Collections.Generic; 5 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Metadata; 9 | 10 | public class ScalerMetadataAccessorTest 11 | { 12 | [Fact] 13 | public void GivenNoMetadata_WhenAccessingMetadata_ThenReturnNull() 14 | => Assert.Null(new ScalerMetadataAccessor().ScalerMetadata); 15 | 16 | [Fact] 17 | public void GivenMetadata_WhenAccessingMetadata_ThenReturnMetadata() 18 | { 19 | IReadOnlyDictionary metadata = new Dictionary { { "key", "value" } }; 20 | ScalerMetadataAccessor accessor = new() { ScalerMetadata = metadata }; 21 | Assert.Same(metadata, accessor.ScalerMetadata); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/TaskHubOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | 8 | /// 9 | /// Represents the configurations for a Durable Task Hub. 10 | /// 11 | public class TaskHubOptions 12 | { 13 | /// 14 | public int MaxActivitiesPerWorker { get; set; } 15 | 16 | /// 17 | public int MaxOrchestrationsPerWorker { get; set; } 18 | 19 | /// 20 | public string TaskHubName { get; set; } = default!; 21 | 22 | /// 23 | public bool UseTablePartitionManagement { get; set; } 24 | } 25 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Metadata/IServiceCollection.Extensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Metadata; 9 | 10 | internal static class IServiceCollectionExtensions 11 | { 12 | public static IServiceCollection AddScalerMetadata(this IServiceCollection services) 13 | { 14 | ArgumentNullException.ThrowIfNull(services); 15 | 16 | return services 17 | .AddScoped() 18 | .AddScoped, ConfigureScalerOptions>() 19 | .AddSingleton, ValidateTaskHubScalerOptions>() 20 | .AddSingleton, ValidateScalerOptions>(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/CloudEnvironment.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 5 | 6 | /// 7 | /// Represents an Azure cloud environment. 8 | /// 9 | public enum CloudEnvironment 10 | { 11 | /// 12 | /// Specifies an unknown Azure cloud. 13 | /// 14 | Unknown, 15 | 16 | /// 17 | /// Specifies Azure Stack Hub or an air-gapped cloud. 18 | /// 19 | Private, 20 | 21 | /// 22 | /// Specifies the public Azure cloud. 23 | /// 24 | AzurePublicCloud, 25 | 26 | /// 27 | /// Specifies the US government Azure cloud. 28 | /// 29 | AzureUSGovernmentCloud, 30 | 31 | /// 32 | /// Specifies the Chinese Azure cloud. 33 | /// 34 | AzureChinaCloud, 35 | } 36 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/ClientCertificateValidationOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 8 | 9 | // Based on https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Configuration.FileExtensions/src/FileConfigurationSource.cs#L14 10 | internal sealed class ClientCertificateValidationOptions 11 | { 12 | public const string DefaultKey = "Kestrel:Client:Certificate:Validation"; 13 | public const string DefaultCachingKey = $"{DefaultKey}:Caching"; 14 | 15 | public bool Enable { get; set; } = true; 16 | 17 | [ValidateObjectMembers] 18 | public CaCertificateFileOptions? CertificateAuthority { get; set; } 19 | 20 | public X509RevocationMode RevocationMode { get; set; } = X509RevocationMode.Online; 21 | } 22 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/TablePartitionManager.Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | 8 | internal static partial class Log 9 | { 10 | [LoggerMessage( 11 | EventId = 4, 12 | Level = LogLevel.Debug, 13 | Message = "Found Task Hub '{TaskHubName}' with {Partitions} partitions in table '{TaskHubTableName}'.")] 14 | public static partial void FoundTaskHubPartitionsTable(this ILogger logger, string taskHubName, int partitions, string taskHubTableName); 15 | 16 | [LoggerMessage( 17 | EventId = 5, 18 | Level = LogLevel.Warning, 19 | Message = "Cannot find Task Hub '{TaskHubName}' partitions table blob '{TaskHubTableName}'.")] 20 | public static partial void CannotFindTaskHubPartitionsTable(this ILogger logger, string taskHubName, string taskHubTableName); 21 | } 22 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Gen/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/SourceGenerationContext.PropertyNames.g.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | #nullable enable annotations 4 | #nullable disable warnings 5 | 6 | // Suppress warnings about [Obsolete] member usage in generated code. 7 | #pragma warning disable CS0612, CS0618 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Json 10 | { 11 | internal partial class SourceGenerationContext 12 | { 13 | private static readonly global::System.Text.Json.JsonEncodedText PropName_createdAt = global::System.Text.Json.JsonEncodedText.Encode("createdAt"); 14 | private static readonly global::System.Text.Json.JsonEncodedText PropName_partitionCount = global::System.Text.Json.JsonEncodedText.Encode("partitionCount"); 15 | private static readonly global::System.Text.Json.JsonEncodedText PropName_taskHubName = global::System.Text.Json.JsonEncodedText.Encode("taskHubName"); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | paths: 9 | - global.json 10 | - NuGet.config 11 | - src/** 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | security-events: write 20 | 21 | steps: 22 | - name: Checkout Repository 23 | uses: actions/checkout@v6 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v4 27 | with: 28 | languages: 'csharp' 29 | 30 | - name: Setup 31 | uses: actions/setup-dotnet@v5 32 | 33 | - name: Build 34 | run: | 35 | dotnet build ./src/Scaler.sln -c Release -p:ContinuousIntegrationBuild=true -warnaserror 36 | dotnet build ./tests/ScaleTests.sln -c Release -p:ContinuousIntegrationBuild=true -warnaserror 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v4 40 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/ControlQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.Globalization; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 9 | 10 | internal static class ControlQueue 11 | { 12 | // See: https://learn.microsoft.com/en-us/rest/api/storageservices/naming-queues-and-metadata#queue-names 13 | [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Queue names must be lowercase.")] 14 | public static string GetName(string taskHub, int partition) 15 | { 16 | ArgumentException.ThrowIfNullOrWhiteSpace(taskHub); 17 | ArgumentOutOfRangeException.ThrowIfLessThan(partition, 0); 18 | ArgumentOutOfRangeException.ThrowIfGreaterThan(partition, 15); 19 | 20 | return string.Format(CultureInfo.InvariantCulture, "{0}-control-{1:D2}", taskHub.ToLowerInvariant(), partition); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/CaCertificateReaderMiddleware.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Http; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 10 | 11 | internal sealed class CaCertificateReaderMiddleware(RequestDelegate next, ReaderWriterLockSlim certificateLock) 12 | { 13 | private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next)); 14 | private readonly ReaderWriterLockSlim _certificateLock = certificateLock ?? throw new ArgumentNullException(nameof(certificateLock)); 15 | 16 | public async Task InvokeAsync(HttpContext context) 17 | { 18 | try 19 | { 20 | _certificateLock.EnterReadLock(); 21 | await _next(context); 22 | } 23 | finally 24 | { 25 | _certificateLock.ExitReadLock(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /charts/azurite/values.yaml: -------------------------------------------------------------------------------- 1 | # Default names are derived from the name of the chart 2 | name: "" 3 | fullnameOverride: "" 4 | 5 | # Docker image metadata 6 | image: 7 | repository: mcr.microsoft.com/azure-storage/azurite 8 | tag: latest 9 | pullPolicy: Always 10 | 11 | # Maximum size of the storage volume 12 | storage: 13 | limit: "" 14 | 15 | # Optional values for debugging storage requests 16 | debug: 17 | enable: false 18 | 19 | # Container resource requests and limits 20 | resources: 21 | requests: 22 | cpu: 100m 23 | memory: 128M 24 | limits: 25 | cpu: 500m 26 | memory: 512M 27 | 28 | # Security context for the azurite container 29 | securityContext: 30 | capabilities: 31 | drop: 32 | - ALL 33 | allowPrivilegeEscalation: false 34 | readOnlyRootFilesystem: true 35 | seccompProfile: 36 | type: RuntimeDefault 37 | 38 | # Security context for all containers in the azurite pods 39 | podSecurityContext: 40 | runAsNonRoot: true 41 | runAsUser: 1000 42 | # runAsGroup: 1000 43 | # fsGroup: 1000 44 | -------------------------------------------------------------------------------- /.github/actions/code-coverage/action.yml: -------------------------------------------------------------------------------- 1 | name: Check Code Coverage 2 | description: Validates code coverage and uploads the results. 3 | inputs: 4 | codecovToken: 5 | description: The Codecov upload token 6 | required: true 7 | reportPath: 8 | description: The path to the cobertura coverage file. 9 | required: true 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Copy Code Coverage 14 | shell: pwsh 15 | run: | 16 | New-Item -ItemType 'directory' -Path '${{ runner.temp }}/codecoverage/' | Out-Null 17 | Copy-Item '${{ inputs.reportPath }}' -Destination '${{ runner.temp }}/codecoverage/' 18 | 19 | - name: Upload Code Coverage Report 20 | uses: actions/upload-artifact@v6 21 | with: 22 | name: coverage 23 | path: ${{ runner.temp }}/codecoverage 24 | 25 | - name: Codecov Upload 26 | uses: codecov/codecov-action@v5 27 | with: 28 | directory: ${{ runner.temp }}/codecoverage/ 29 | fail_ci_if_error: true 30 | token: ${{ inputs.codecovToken }} 31 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/DataAnnotations/FileExistsAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.ComponentModel.DataAnnotations; 6 | using System.IO; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.DataAnnotations; 9 | 10 | internal sealed class FileExistsAttribute : ValidationAttribute 11 | { 12 | protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) 13 | { 14 | ArgumentNullException.ThrowIfNull(validationContext); 15 | 16 | if (value is not null) 17 | { 18 | if (value is not string filePath) 19 | return new ValidationResult(SRF.Format(SRF.InvalidMemberTypeFormat, "string", value.GetType().Name), [validationContext.MemberName!]); 20 | 21 | if (!File.Exists(filePath)) 22 | return new ValidationResult(SRF.Format(SRF.FileNotFound, filePath), [validationContext.MemberName!]); 23 | } 24 | 25 | return ValidationResult.Success; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/BlobPartitionManager.Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Logging; 5 | using System; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 8 | 9 | internal static partial class Log 10 | { 11 | [LoggerMessage( 12 | EventId = 4, 13 | Level = LogLevel.Debug, 14 | Message = "Found Task Hub '{TaskHubName}' with {Partitions} partitions created at {CreatedTime:O} in blob {TaskHubBlobName}.")] 15 | public static partial void FoundTaskHubBlob(this ILogger logger, string taskHubName, int partitions, DateTimeOffset createdTime, string taskHubBlobName); 16 | 17 | [LoggerMessage( 18 | EventId = 5, 19 | Level = LogLevel.Warning, 20 | Message = "Cannot find Task Hub '{TaskHubName}' metadata blob '{TaskHubBlobName}' in container '{LeaseContainerName}'.")] 21 | public static partial void CannotFindTaskHubBlob(this ILogger logger, string taskHubName, string taskHubBlobName, string leaseContainerName); 22 | } 23 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/ConfigureTaskHubOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 9 | 10 | internal sealed class ConfigureTaskHubOptions(IOptionsSnapshot scalerOptions) : IConfigureOptions 11 | { 12 | private readonly ScalerOptions _scalerOptions = scalerOptions?.Get(default) ?? throw new ArgumentNullException(nameof(scalerOptions)); 13 | 14 | public void Configure(TaskHubOptions options) 15 | { 16 | ArgumentNullException.ThrowIfNull(options); 17 | 18 | options.MaxActivitiesPerWorker = _scalerOptions.MaxActivitiesPerWorker; 19 | options.MaxOrchestrationsPerWorker = _scalerOptions.MaxOrchestrationsPerWorker; 20 | options.TaskHubName = _scalerOptions.TaskHubName; 21 | options.UseTablePartitionManagement = _scalerOptions.UseTablePartitionManagement; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Interceptors/ExceptionInterceptor.Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Interceptors; 8 | 9 | internal static partial class Log 10 | { 11 | [LoggerMessage( 12 | EventId = 1, 13 | Level = LogLevel.Critical, 14 | Message = "Caught unhandled exception!")] 15 | public static partial void CaughtUnhandledException(this ILogger logger, Exception exception); 16 | 17 | [LoggerMessage( 18 | EventId = 2, 19 | Level = LogLevel.Error, 20 | Message = "Request contains invalid input.")] 21 | public static partial void ReceivedInvalidInput(this ILogger logger, Exception exception); 22 | 23 | [LoggerMessage( 24 | EventId = 3, 25 | Level = LogLevel.Warning, 26 | Message = "RPC operation canceled.")] 27 | public static partial void DetectedRequestCancellation(this ILogger logger, OperationCanceledException exception); 28 | } 29 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Metadata/ConfigureScalerOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Metadata; 10 | 11 | internal class ConfigureScalerOptions(IScalerMetadataAccessor metadataAccessor) : IConfigureOptions 12 | { 13 | private readonly IScalerMetadataAccessor _metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); 14 | 15 | public void Configure(ScalerOptions options) 16 | { 17 | ArgumentNullException.ThrowIfNull(options); 18 | 19 | IReadOnlyDictionary metadata = _metadataAccessor.ScalerMetadata ?? throw new InvalidOperationException(SR.ScalerMetadataNotFound); 20 | IConfiguration config = new ConfigurationBuilder() 21 | .AddInMemoryCollection(metadata) 22 | .Build(); 23 | 24 | config.Bind(options); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | 3 | ### Pipelines ### 4 | certs/ 5 | 6 | ### VisualStudio ### 7 | 8 | # Tool Runtime Dir 9 | .dotnet/ 10 | .packages/ 11 | .store/ 12 | .tools/ 13 | 14 | # User-specific files 15 | *.suo 16 | *.user 17 | *.userosscache 18 | *.sln.docstates 19 | 20 | # Build results 21 | artifacts/ 22 | .idea/ 23 | [Dd]ebug/ 24 | [Dd]ebugPublic/ 25 | [Rr]elease/ 26 | [Rr]eleases/ 27 | bld/ 28 | [Bb]in/ 29 | [Oo]bj/ 30 | msbuild.log 31 | msbuild.err 32 | msbuild.wrn 33 | msbuild.binlog 34 | .deps/ 35 | .dirstamp 36 | .libs/ 37 | *.lo 38 | *.o 39 | 40 | # Visual Studio 41 | .vs/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Visual Studio profiler 73 | *.psess 74 | *.vsp 75 | *.vspx 76 | 77 | # NuGet Packages 78 | *.nuget.props 79 | *.nuget.targets 80 | *.nupkg 81 | *.snupkg 82 | **/packages/* 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 William Sugarman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/TaskHub.Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | 8 | internal static partial class Log 9 | { 10 | [LoggerMessage( 11 | EventId = 6, 12 | Level = LogLevel.Warning, 13 | Message = "Could not find control queue '{ControlQueueName}'.")] 14 | public static partial void CouldNotFindControlQueue(this ILogger logger, string controlQueueName); 15 | 16 | [LoggerMessage( 17 | EventId = 7, 18 | Level = LogLevel.Warning, 19 | Message = "Could not find work item queue '{WorkItemQueueName}'.")] 20 | public static partial void CouldNotFindWorkItemQueue(this ILogger logger, string workItemQueueName); 21 | 22 | [LoggerMessage( 23 | EventId = 8, 24 | Level = LogLevel.Debug, 25 | Message = "Found {WorkItemCount} work item messages and the following control queue message counts [{ControlCounts}].")] 26 | public static partial void FoundTaskHubQueues(this ILogger logger, int workItemCount, string controlCounts); 27 | } 28 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/WorkItemQueue.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 9 | 10 | public class WorkItemQueueTest 11 | { 12 | [Fact] 13 | public void GivenNullTaskHub_WhenGettingWorkItemQueueName_ThenThrowArgumentException() 14 | => Assert.Throws(() => WorkItemQueue.GetName(null!)); 15 | 16 | [Theory] 17 | [InlineData("")] 18 | [InlineData(" \t ")] 19 | public void GivenEmptyOrWhiteSpaceTaskHub_WhenGettingWorkItemQueueName_ThenThrowArgumentException(string taskHub) 20 | => Assert.Throws(() => WorkItemQueue.GetName(taskHub)); 21 | 22 | [Theory] 23 | [InlineData("foo-workitems", "foo")] 24 | [InlineData("bar-workitems", "bar")] 25 | [InlineData("baz-workitems", "BAZ")] 26 | public void GivenTaskHub_WhenGettingWorkItemQueueName_ThenReturnExpectedValue(string expected, string taskHub) 27 | => Assert.Equal(expected, WorkItemQueue.GetName(taskHub)); 28 | } 29 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/LeasesContainer.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 9 | 10 | public class LeasesContainerTest 11 | { 12 | [Fact] 13 | public void GivenNullTaskHub_WhenGettingLeasesContainerName_ThenThrowArgumentException() 14 | => Assert.Throws(() => LeasesContainer.GetName(null!)); 15 | 16 | [Theory] 17 | [InlineData("")] 18 | [InlineData(" \t ")] 19 | public void GivenEmptyOrWhiteSpaceTaskHub_WhenGettingLeasesContainerName_ThenThrowArgumentException(string taskHub) 20 | => Assert.Throws(() => LeasesContainer.GetName(taskHub)); 21 | 22 | [Theory] 23 | [InlineData("foo-leases", "foo")] 24 | [InlineData("bar-leases", "bar")] 25 | [InlineData("baz-leases", "BAZ")] 26 | public void GivenTaskHub_WhenGettingLeasesContainerName_ThenReturnExpectedValue(string expected, string taskHub) 27 | => Assert.Equal(expected, LeasesContainer.GetName(taskHub)); 28 | } 29 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/PartitionsTable.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 9 | 10 | public class PartitionsTableTest 11 | { 12 | [Fact] 13 | public void GivenNullTaskHub_WhenGettingPartitionsTableName_ThenThrowArgumentException() 14 | => Assert.Throws(() => PartitionsTable.GetName(null!)); 15 | 16 | [Theory] 17 | [InlineData("")] 18 | [InlineData(" \t ")] 19 | public void GivenEmptyOrWhiteSpaceTaskHub_WhenGettingPartitionsTableName_ThenThrowArgumentException(string taskHub) 20 | => Assert.Throws(() => PartitionsTable.GetName(taskHub)); 21 | 22 | [Theory] 23 | [InlineData("fooPartitions", "foo")] 24 | [InlineData("BarPartitions", "Bar")] 25 | [InlineData("BAZPartitions", "BAZ")] 26 | public void GivenTaskHub_WhenGettingPartitionsTableName_ThenReturnExpectedValue(string expected, string taskHub) 27 | => Assert.Equal(expected, PartitionsTable.GetName(taskHub)); 28 | } 29 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/ITaskHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 9 | 10 | /// 11 | /// Represents a Durable Task Hub using the Azure Storage backend provider. 12 | /// 13 | public interface ITaskHub 14 | { 15 | /// 16 | /// Asynchronously fetches the number of messages in queue across the Task Hub to gauge its usage. 17 | /// 18 | /// 19 | /// The token to monitor for cancellation requests. The default value is . 20 | /// 21 | /// 22 | /// A value task that represents the asynchronous operation. The value of the type parameter 23 | /// of the value task contains the usage for the Task Hub. 24 | /// 25 | /// The is canceled. 26 | ValueTask GetUsageAsync(CancellationToken cancellationToken = default); 27 | } 28 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TestEnvironment.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.Test; 7 | 8 | internal static class TestEnvironment 9 | { 10 | public static IDisposable SetVariable(string key, string? value) 11 | { 12 | string? previous = Environment.GetEnvironmentVariable(key, EnvironmentVariableTarget.Process); 13 | Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Process); 14 | return new VariableReplacement(key, previous); 15 | } 16 | 17 | private sealed class VariableReplacement(string key, string? value) : IDisposable 18 | { 19 | public string Key { get; } = key ?? throw new ArgumentNullException(nameof(key)); 20 | 21 | public string? Value { get; } = value; 22 | 23 | private bool _disposed; 24 | 25 | public void Dispose() 26 | { 27 | if (!_disposed) 28 | { 29 | Environment.SetEnvironmentVariable(Key, Value, EnvironmentVariableTarget.Process); 30 | _disposed = true; 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Protos/externalscaler.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option csharp_namespace = "Keda.Scaler.DurableTask.AzureStorage"; 4 | 5 | package externalscaler; 6 | 7 | service ExternalScaler { 8 | rpc IsActive(ScaledObjectRef) returns (IsActiveResponse) {} 9 | rpc StreamIsActive(ScaledObjectRef) returns (stream IsActiveResponse) {} 10 | rpc GetMetricSpec(ScaledObjectRef) returns (GetMetricSpecResponse) {} 11 | rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {} 12 | } 13 | 14 | message ScaledObjectRef { 15 | string name = 1; 16 | string namespace = 2; 17 | map scalerMetadata = 3; 18 | } 19 | 20 | message IsActiveResponse { 21 | bool result = 1; 22 | } 23 | 24 | message GetMetricSpecResponse { 25 | repeated MetricSpec metricSpecs = 1; 26 | } 27 | 28 | message MetricSpec { 29 | string metricName = 1; 30 | int64 targetSize = 2; 31 | } 32 | 33 | message GetMetricsRequest { 34 | ScaledObjectRef scaledObjectRef = 1; 35 | string metricName = 2; 36 | } 37 | 38 | message GetMetricsResponse { 39 | repeated MetricValue metricValues = 1; 40 | } 41 | 42 | message MetricValue { 43 | string metricName = 1; 44 | int64 metricValue = 2; 45 | } 46 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/ValidateAzureStorageAccountOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 9 | 10 | internal sealed class ValidateAzureStorageAccountOptions(IOptionsSnapshot scalerOptions) : IValidateOptions 11 | { 12 | private readonly ScalerOptions _scalerOptions = scalerOptions?.Get(default) ?? throw new ArgumentNullException(nameof(scalerOptions)); 13 | 14 | public ValidateOptionsResult Validate(string? name, AzureStorageAccountOptions options) 15 | { 16 | ArgumentNullException.ThrowIfNull(options); 17 | 18 | if (options.AccountName is null && string.IsNullOrWhiteSpace(options.ConnectionString)) 19 | { 20 | string failure = SRF.Format(SRF.InvalidConnectionEnvironmentVariable, _scalerOptions.ConnectionFromEnv ?? AzureStorageAccountOptions.DefaultConnectionEnvironmentVariable); 21 | return ValidateOptionsResult.Fail(failure); 22 | } 23 | 24 | return ValidateOptionsResult.Success; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Certificates/RSA.Extensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Security.Cryptography.X509Certificates; 6 | using System.Security.Cryptography; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Certificates; 9 | 10 | internal static class RSAExtensions 11 | { 12 | public static X509Certificate2 CreateSelfSignedCertificate(this RSA key) 13 | { 14 | ArgumentNullException.ThrowIfNull(key); 15 | 16 | CertificateRequest certRequest = new("cn=unittest", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) 17 | { 18 | CertificateExtensions = 19 | { 20 | { new X509BasicConstraintsExtension( 21 | certificateAuthority: true, 22 | hasPathLengthConstraint: true, 23 | pathLengthConstraint: 10, 24 | critical: false) 25 | }, 26 | { new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, false) }, 27 | } 28 | }; 29 | 30 | return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddHours(-2), DateTimeOffset.UtcNow.AddHours(2)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Clients/BlobServiceClientFactory.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Azure.Storage.Blobs; 6 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 7 | using Xunit; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Clients; 10 | 11 | public class BlobServiceClientFactoryTest : AzureStorageAccountClientFactoryTest 12 | { 13 | protected override AzureStorageAccountClientFactory GetFactory() 14 | => new BlobServiceClientFactory(); 15 | 16 | protected override void ValidateAccountName(BlobServiceClient actual, string accountName, string endpointSuffix) 17 | => Validate(actual, accountName, AzureStorageServiceUri.Create(accountName, AzureStorageService.Blob, endpointSuffix)); 18 | 19 | protected override void ValidateEmulator(BlobServiceClient actual) 20 | => Validate(actual, "devstoreaccount1", new Uri("http://127.0.0.1:10000/devstoreaccount1", UriKind.Absolute)); 21 | 22 | private static void Validate(BlobServiceClient actual, string accountName, Uri serviceUrl) 23 | { 24 | Assert.Equal(accountName, actual?.AccountName); 25 | Assert.Equal(serviceUrl, actual?.Uri); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":dependencyDashboard", 5 | ":semanticPrefixFixDepsChoreOthers", 6 | "group:dotNetCore", 7 | "group:monorepos", 8 | "group:recommended", 9 | "replacements:all", 10 | "workarounds:all" 11 | ], 12 | "labels": [ 13 | "dependencies" 14 | ], 15 | "packageRules": [ 16 | { 17 | "groupName": "Artifact Actions", 18 | "matchPackageNames": [ 19 | "actions/upload-artifact", 20 | "actions/download-artifact" 21 | ] 22 | }, 23 | { 24 | "groupName": "Dotnet", 25 | "matchPackageNames": [ 26 | "dotnet-sdk", 27 | "mcr.microsoft.com/dotnet/aspnet", 28 | "mcr.microsoft.com/dotnet/sdk" 29 | ] 30 | }, 31 | { 32 | "groupName": "gRPC", 33 | "matchPackageNames": [ 34 | "Grpc.**" 35 | ] 36 | }, 37 | { 38 | "groupName": "NSubstitute", 39 | "matchPackageNames": [ 40 | "NSubstitute**" 41 | ] 42 | }, 43 | { 44 | "groupName": "XUnit", 45 | "matchPackageNames": [ 46 | "xunit**", 47 | "MartinCostello.Logging.XUnit**" 48 | ] 49 | } 50 | ], 51 | "prConcurrentLimit": 0, 52 | "prHourlyLimit": 0 53 | } 54 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Clients/QueueServiceClientFactory.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Azure.Storage.Queues; 6 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 7 | using Xunit; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Clients; 10 | 11 | public class QueueServiceClientFactoryTest : AzureStorageAccountClientFactoryTest 12 | { 13 | protected override AzureStorageAccountClientFactory GetFactory() 14 | => new QueueServiceClientFactory(); 15 | 16 | protected override void ValidateAccountName(QueueServiceClient actual, string accountName, string endpointSuffix) 17 | => Validate(actual, accountName, AzureStorageServiceUri.Create(accountName, AzureStorageService.Queue, endpointSuffix)); 18 | 19 | protected override void ValidateEmulator(QueueServiceClient actual) 20 | => Validate(actual, "devstoreaccount1", new Uri("http://127.0.0.1:10001/devstoreaccount1", UriKind.Absolute)); 21 | 22 | private static void Validate(QueueServiceClient actual, string accountName, Uri serviceUrl) 23 | { 24 | Assert.Equal(accountName, actual?.AccountName); 25 | Assert.Equal(serviceUrl, actual?.Uri); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/ITaskHubPartitionManager.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 10 | 11 | /// 12 | /// Represents a manager for orchestration partitions in a Durable Task Hub that uses the Azure Storage backend. 13 | /// 14 | public interface ITaskHubPartitionManager 15 | { 16 | /// 17 | /// Asynchronously enumerates the partition IDs used by the Durable Task Hub. 18 | /// 19 | /// 20 | /// The token to monitor for cancellation requests. The default value is . 21 | /// 22 | /// 23 | /// A value task that represents the asynchronous operation. The value of the type parameter 24 | /// of the value task contains a list of the partition IDs. 25 | /// 26 | /// The is canceled. 27 | ValueTask> GetPartitionsAsync(CancellationToken cancellationToken = default); 28 | } 29 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Interceptors/ScalerMetadataInterceptor.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Grpc.Core; 7 | using Grpc.Core.Interceptors; 8 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 9 | 10 | namespace Keda.Scaler.DurableTask.AzureStorage.Interceptors; 11 | 12 | internal sealed class ScalerMetadataInterceptor(IScalerMetadataAccessor accessor) : Interceptor 13 | { 14 | private readonly IScalerMetadataAccessor _accessor = accessor ?? throw new ArgumentNullException(nameof(accessor)); 15 | 16 | public override Task UnaryServerHandler( 17 | TRequest request, 18 | ServerCallContext context, 19 | UnaryServerMethod continuation) 20 | { 21 | ArgumentNullException.ThrowIfNull(request); 22 | ArgumentNullException.ThrowIfNull(context); 23 | ArgumentNullException.ThrowIfNull(continuation); 24 | 25 | _accessor.ScalerMetadata = request switch 26 | { 27 | GetMetricsRequest r => r.ScaledObjectRef.ScalerMetadata, 28 | ScaledObjectRef r => r.ScalerMetadata, 29 | _ => null, 30 | }; 31 | 32 | return continuation(request, context); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.DurableTask.AzureStorage.Test.Integration/AzureStorageDurableTaskClientOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.ComponentModel.DataAnnotations; 6 | using System.Diagnostics.CodeAnalysis; 7 | using DurableTask.AzureStorage; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Integration; 10 | 11 | [SuppressMessage("Microsoft.Performance", "CA1812:Avoid uninstantiated internal classes.", Justification = "This class is instantiated via dependency injection.")] 12 | internal sealed class AzureStorageDurableTaskClientOptions 13 | { 14 | public const string DefaultSectionName = "DurableTask"; 15 | 16 | [Required] 17 | public string ConnectionString { get; set; } = "UseDevelopmentStorage=true"; 18 | 19 | [Range(1, 16)] 20 | public int PartitionCount { get; set; } = 4; 21 | 22 | [Required] 23 | public string TaskHubName { get; set; } = "TestHubName"; 24 | 25 | public AzureStorageOrchestrationServiceSettings ToOrchestrationServiceSettings() 26 | { 27 | return new() 28 | { 29 | PartitionCount = PartitionCount, 30 | TaskHubName = TaskHubName, 31 | StorageAccountClientProvider = new StorageAccountClientProvider(ConnectionString) 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /charts/example-function-app/templates/04-scaledobject.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: keda.sh/v1alpha1 2 | kind: ScaledObject 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: {{ template "example-function-app.fullname" . }} 6 | {{- include "example-function-app.labels" . | indent 4 }} 7 | name: {{ template "example-function-app.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | spec: 10 | scaleTargetRef: 11 | name: {{ template "example-function-app.fullname" . }} 12 | kind: Deployment 13 | cooldownPeriod: {{ .Values.scaledObject.cooldownPeriod }} 14 | pollingInterval: {{ .Values.scaledObject.pollingInterval }} 15 | minReplicaCount: {{ .Values.scaledObject.minReplicaCount }} 16 | maxReplicaCount: {{ .Values.scaledObject.maxReplicaCount }} 17 | triggers: 18 | - type: external 19 | metadata: 20 | scalerAddress: "{{ .Values.externalScaler.serviceName }}.{{ .Values.externalScaler.namespace }}:{{ .Values.externalScaler.port }}" 21 | connection: {{ .Values.taskHub.connectionString }} 22 | maxActivitiesPerWorker: {{ .Values.taskHub.maxActivitiesPerWorker | quote }} 23 | maxOrchestrationsPerWorker: {{ .Values.taskHub.maxOrchestrationsPerWorker | quote }} 24 | taskHubName: {{ .Values.taskHub.name }} 25 | authenticationRef: 26 | name: {{ template "example-function-app.fullname" . }} 27 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/AzureStorageServiceUri.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Globalization; 5 | using System; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 8 | 9 | internal static class AzureStorageServiceUri 10 | { 11 | public const string PublicSuffix = "core.windows.net"; 12 | 13 | public const string USGovernmentSuffix = "core.usgovcloudapi.net"; 14 | 15 | public const string ChinaSuffix = "core.chinacloudapi.cn"; 16 | 17 | public static Uri Create(string accountName, AzureStorageService service, string endpointSuffix) 18 | { 19 | ArgumentException.ThrowIfNullOrWhiteSpace(accountName); 20 | ArgumentException.ThrowIfNullOrWhiteSpace(endpointSuffix); 21 | 22 | if (!Enum.IsDefined(service)) 23 | throw new ArgumentOutOfRangeException(nameof(service)); 24 | 25 | #pragma warning disable CA1308 // Normalize strings to uppercase 26 | return new Uri( 27 | string.Format( 28 | CultureInfo.InvariantCulture, 29 | "https://{0}.{1}.{2}", 30 | accountName, 31 | service.ToString("G").ToLowerInvariant(), 32 | endpointSuffix), 33 | UriKind.Absolute); 34 | #pragma warning restore CA1308 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/IServiceCollection.Extensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 9 | 10 | internal static class IServiceCollectionExtensions 11 | { 12 | public static IServiceCollection AddDurableTaskScaleManager(this IServiceCollection services) 13 | { 14 | ArgumentNullException.ThrowIfNull(services); 15 | 16 | return services 17 | .AddScoped, ConfigureTaskHubOptions>() 18 | .AddScoped() 19 | .AddScoped() 20 | .AddScoped(x => 21 | { 22 | IOptionsSnapshot options = x.GetRequiredService>(); 23 | return options.Get(default).UseTablePartitionManagement 24 | ? x.GetRequiredService() 25 | : x.GetRequiredService(); 26 | }) 27 | .AddScoped() 28 | .AddScoped(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/actions/parse-chart/action.yml: -------------------------------------------------------------------------------- 1 | name: parse chart 2 | description: Retrieves chart metadata from the chart.yaml file 3 | inputs: 4 | workflowRunId: 5 | default: '' 6 | description: The optional workflow run id for Pull Requests 7 | required: false 8 | outputs: 9 | assemblyFileVersion: 10 | description: The assembly file version for the scaler assembly 11 | value: ${{ steps.parse.outputs.assemblyFileVersion }} 12 | assemblyVersion: 13 | description: The assembly version for the scaler assembly 14 | value: ${{ steps.parse.outputs.assemblyVersion }} 15 | chartPrerelease: 16 | description: Indicates whether the Helm chart is a prerelease version 17 | value: ${{ steps.parse.outputs.chartPrerelease }} 18 | chartVersion: 19 | description: The scaler helm chart version 20 | value: ${{ steps.parse.outputs.chartVersion }} 21 | imageTag: 22 | description: The scaler image tag 23 | value: ${{ steps.parse.outputs.imageTag }} 24 | imagePrerelease: 25 | description: Indicates whether the scaler image is a prerelease version 26 | value: ${{ steps.parse.outputs.imagePrerelease }} 27 | 28 | runs: 29 | using: composite 30 | steps: 31 | - name: Parse Chart.yaml 32 | id: parse 33 | shell: pwsh 34 | run: ./.github/actions/parse-chart/scripts/ParseVersions.ps1 -WorkflowRunId '${{ inputs.workflowRunId }}' 35 | -------------------------------------------------------------------------------- /.github/actions/github-release/action.yml: -------------------------------------------------------------------------------- 1 | name: github release 2 | description: Creates a GitHub release for a NuGet package 3 | inputs: 4 | asset: 5 | description: The path to the file or folder containing the release assets 6 | required: true 7 | name: 8 | description: The name of the component 9 | required: true 10 | prerelease: 11 | description: Indicates whether the GitHub release is a prerelease version 12 | required: true 13 | tag: 14 | description: The Git tag to create and associate with the release 15 | required: true 16 | version: 17 | description: The version of the GitHub release 18 | required: true 19 | 20 | runs: 21 | using: composite 22 | steps: 23 | - name: Create Release 24 | uses: actions/github-script@v8 25 | with: 26 | script: | 27 | const { default: createRelease } = await import('${{ github.workspace }}/.github/actions/github-release/scripts/CreateRelease.mjs'); 28 | await createRelease({ 29 | github: github, 30 | context: context, 31 | release: { 32 | asset: '${{ inputs.asset }}', 33 | name: '${{ inputs.name }}', 34 | prerelease: '${{ inputs.prerelease }}'.toLowerCase() === 'true', 35 | tag: '${{ inputs.tag }}', 36 | version: '${{ inputs.version }}', 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/DurableTaskScaleManager.Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | 8 | internal static partial class Log 9 | { 10 | [LoggerMessage( 11 | EventId = 9, 12 | Level = LogLevel.Information, 13 | Message = "Metric value for Task Hub '{TaskHubName}' is {MetricValue}.")] 14 | public static partial void ComputedScalerMetricValue(this ILogger logger, string taskHubName, long metricValue); 15 | 16 | [LoggerMessage( 17 | EventId = 10, 18 | Level = LogLevel.Information, 19 | Message = "Metric target for Task Hub '{TaskHubName}' is {MetricTarget}.")] 20 | public static partial void ComputedScalerMetricTarget(this ILogger logger, string taskHubName, long metricTarget); 21 | 22 | [LoggerMessage( 23 | EventId = 11, 24 | Level = LogLevel.Information, 25 | Message = "Task Hub '{TaskHubName}' is currently active.")] 26 | public static partial void DetectedActiveTaskHub(this ILogger logger, string taskHubName); 27 | 28 | [LoggerMessage( 29 | EventId = 12, 30 | Level = LogLevel.Information, 31 | Message = "Task Hub '{TaskHubName}' is not currently active.")] 32 | public static partial void DetectedInactiveTaskHub(this ILogger logger, string taskHubName); 33 | } 34 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/AzureStorageAccountOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Azure.Identity; 5 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 8 | 9 | /// 10 | /// Represents a collection of metadata that specifies the connection for a particular Azure Storage account. 11 | /// 12 | public sealed class AzureStorageAccountOptions 13 | { 14 | internal const string DefaultConnectionEnvironmentVariable = "AzureWebJobsStorage"; 15 | 16 | /// 17 | public string? AccountName { get; set; } 18 | 19 | /// 20 | /// Gets the optional connection string for Azure Storage. 21 | /// 22 | /// The Azure Storage connection string if specified; otherwise . 23 | public string? ConnectionString { get; set; } 24 | 25 | /// 26 | public string? EndpointSuffix { get; set; } 27 | 28 | /// 29 | /// Gets or sets the optional token credential used for managed identity within a Kubernetes cluster. 30 | /// 31 | /// An optional if specified; otherwise, . 32 | public WorkloadIdentityCredential? TokenCredential { get; set; } 33 | } 34 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/ControlQueue.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 9 | 10 | public class ControlQueueTest 11 | { 12 | [Fact] 13 | public void GivenNullTaskHub_WhenGettingControlQueueName_ThenThrowArgumentException() 14 | => Assert.Throws(() => LeasesContainer.GetName(null!)); 15 | 16 | [Theory] 17 | [InlineData("")] 18 | [InlineData(" \t ")] 19 | public void GivenEmptyOrWhiteSpaceTaskHub_WhenGettingControlQueueName_ThenThrowArgumentException(string taskHub) 20 | => Assert.Throws(() => LeasesContainer.GetName(taskHub)); 21 | 22 | [Theory] 23 | [InlineData(-2)] 24 | [InlineData(19)] 25 | public void GivenInvalidPartition_WhenGettingControlQueueName_ThenThrowArgumentOutOfRangeException(int partition) 26 | => Assert.Throws(() => ControlQueue.GetName("foo", partition)); 27 | 28 | [Theory] 29 | [InlineData("foo-control-00", "foo", 0)] 30 | [InlineData("bar-control-07", "Bar", 7)] 31 | [InlineData("baz-control-15", "BAZ", 15)] 32 | public void GivenTaskHub_WhenGettingControlQueueName_ThenReturnExpectedValue(string expected, string taskHub, int partition) 33 | => Assert.Equal(expected, ControlQueue.GetName(taskHub, partition)); 34 | } 35 | -------------------------------------------------------------------------------- /charts/azurite/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | 3 | {{/* 4 | Expand the name of the chart. 5 | */}} 6 | {{- define "azurite.name" -}} 7 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 8 | {{- end }} 9 | 10 | {{/* 11 | Create a default fully qualified app name. 12 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 13 | If release name contains chart name it will be used as a full name. 14 | */}} 15 | {{- define "azurite.fullname" -}} 16 | {{- if .Values.fullnameOverride }} 17 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 18 | {{- else }} 19 | {{- $name := default .Chart.Name .Values.nameOverride }} 20 | {{- if contains $name .Release.Name }} 21 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 22 | {{- else }} 23 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 24 | {{- end }} 25 | {{- end }} 26 | {{- end }} 27 | 28 | {{/* 29 | Create chart name and version as used by the chart label. 30 | */}} 31 | {{- define "azurite.chart" -}} 32 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 33 | {{- end }} 34 | 35 | {{/* 36 | Common labels 37 | */}} 38 | {{- define "azurite.labels" }} 39 | helm.sh/chart: {{ include "azurite.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | app.kubernetes.io/managed-by: {{ .Release.Service }} 42 | app.kubernetes.io/part-of: {{ .Chart.Name }} 43 | app.kubernetes.io/version: {{ .Chart.Version | quote }} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /charts/example-function-app/values.yaml: -------------------------------------------------------------------------------- 1 | # Default names are derived from the name of the chart 2 | name: "" 3 | fullnameOverride: "" 4 | 5 | taskHub: 6 | name: "TestTaskHub" 7 | connectionString: "" 8 | maxActivitiesPerWorker: 2 9 | maxOrchestrationsPerWorker: 1 10 | partitionCount: 4 11 | 12 | # Docker image metadata 13 | image: 14 | repository: example-function-app 15 | tag: "" 16 | pullPolicy: IfNotPresent 17 | 18 | # Metadata concerning how to scale 19 | scaledObject: 20 | pollingInterval: 5 21 | cooldownPeriod: 15 22 | minReplicaCount: 0 23 | maxReplicaCount: 100 24 | caCertSecret: "" 25 | tlsClientCertSecret: "" 26 | 27 | # External scaler information 28 | externalScaler: 29 | serviceName: durabletask-azurestorage-scaler 30 | namespace: keda 31 | port: 4370 32 | 33 | # Optional upgrade strategy 34 | upgradeStrategy: {} 35 | # type: RollingUpdate 36 | # rollingUpdate: 37 | # maxUnavailable: 1 38 | # maxSurge: 1 39 | 40 | # Container resource requests and limits 41 | resources: 42 | requests: 43 | cpu: 100m 44 | memory: 256M 45 | limits: 46 | cpu: '1' 47 | memory: 1G 48 | 49 | # Security context for the scaler container 50 | securityContext: 51 | capabilities: 52 | drop: 53 | - ALL 54 | allowPrivilegeEscalation: false 55 | readOnlyRootFilesystem: true 56 | seccompProfile: 57 | type: RuntimeDefault 58 | 59 | # Security context for all containers in the scaler pods 60 | podSecurityContext: 61 | runAsNonRoot: true 62 | runAsUser: 1000 63 | # runAsGroup: 2000 64 | # fsGroup: 2000 65 | -------------------------------------------------------------------------------- /charts/durabletask-azurestorage-scaler/templates/01-serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: {{ template "durabletask-azurestorage-scaler.serviceAccountName" . }} 7 | {{- if .Values.podIdentity.enabled }} 8 | azure.workload.identity/use: "true" 9 | {{- end }} 10 | {{- include "durabletask-azurestorage-scaler.labels" . | indent 4 }} 11 | {{- if or .Values.podIdentity.enabled .Values.serviceAccount.annotations .Values.additionalAnnotations }} 12 | annotations: 13 | {{- if .Values.additionalAnnotations }} 14 | {{- toYaml .Values.additionalAnnotations | indent 4 }} 15 | {{- end }} 16 | {{- if .Values.podIdentity.enabled }} 17 | {{- if .Values.podIdentity.clientId }} 18 | azure.workload.identity/client-id: {{ .Values.podIdentity.clientId | quote }} 19 | {{- end }} 20 | {{- if .Values.podIdentity.tenantId }} 21 | azure.workload.identity/tenant-id: {{ .Values.podIdentity.tenantId | quote }} 22 | {{- end }} 23 | azure.workload.identity/service-account-token-expiration: {{ .Values.podIdentity.tokenExpiration | quote }} 24 | {{- end }} 25 | {{- if .Values.serviceAccount.annotations }} 26 | {{- toYaml .Values.serviceAccount.annotations | nindent 4}} 27 | {{- end }} 28 | {{- end }} 29 | name: {{ template "durabletask-azurestorage-scaler.serviceAccountName" . }} 30 | namespace: {{ .Release.Namespace }} 31 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/Keda.Scaler.Functions.Worker.DurableTask.Examples.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | v4 5 | Linux 6 | /home/site/wwwroot 7 | ..\.. 8 | enable 9 | Exe 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | PreserveNewest 28 | 29 | 30 | PreserveNewest 31 | Never 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /charts/example-function-app/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | 3 | {{/* 4 | Expand the name of the chart. 5 | */}} 6 | {{- define "example-function-app.name" -}} 7 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 8 | {{- end }} 9 | 10 | {{/* 11 | Create a default fully qualified app name. 12 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 13 | If release name contains chart name it will be used as a full name. 14 | */}} 15 | {{- define "example-function-app.fullname" -}} 16 | {{- if .Values.fullnameOverride }} 17 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 18 | {{- else }} 19 | {{- $name := default .Chart.Name .Values.nameOverride }} 20 | {{- if contains $name .Release.Name }} 21 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 22 | {{- else }} 23 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 24 | {{- end }} 25 | {{- end }} 26 | {{- end }} 27 | 28 | {{/* 29 | Create chart name and version as used by the chart label. 30 | */}} 31 | {{- define "example-function-app.chart" -}} 32 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 33 | {{- end }} 34 | 35 | {{/* 36 | Common labels 37 | */}} 38 | {{- define "example-function-app.labels" }} 39 | helm.sh/chart: {{ include "example-function-app.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | app.kubernetes.io/managed-by: {{ .Release.Service }} 42 | app.kubernetes.io/part-of: {{ .Chart.Name }} 43 | app.kubernetes.io/version: {{ .Chart.Version | quote }} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/TaskHubQueueUsage.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 9 | 10 | public class TaskHubQueueUsageTest 11 | { 12 | [Fact] 13 | public void GivenNullControlQueueMessages_WhenCreatingTaskHubQueueUsage_ThenThrowArgumentNullException() 14 | => Assert.Throws(() => new TaskHubQueueUsage(null!, 3)); 15 | 16 | [Theory] 17 | [InlineData(-1)] 18 | [InlineData(1, 2, -3)] 19 | public void GivenInvalidControlQueueMessageCount_WhenCreatingTaskHubQueueUsage_ThenThrowArgumentOutOfRangeException(params int[] controlQueueMessages) 20 | => Assert.Throws(() => new TaskHubQueueUsage(controlQueueMessages, 3)); 21 | 22 | [Fact] 23 | public void GivenInvalidWorkItemQueueMessageCount_WhenCreatingTaskHubQueueUsage_ThenThrowArgumentOutOfRangeException() 24 | => Assert.Throws(() => new TaskHubQueueUsage([], -2)); 25 | 26 | [Theory] 27 | [InlineData(false, 0)] 28 | [InlineData(false, 0, 0, 0)] 29 | [InlineData(true, 2)] 30 | [InlineData(true, 1, 0)] 31 | [InlineData(true, 0, 1)] 32 | [InlineData(true, 4, 1, 2, 3, 4)] 33 | public void GivenUsage_WhenQueryingActivity_ThenReturnCorrespondingValue(bool expected, int workItems, params int[] control) 34 | => Assert.Equal(expected, new TaskHubQueueUsage(control, workItems).HasActivity); 35 | } 36 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | FROM mcr.microsoft.com/dotnet/sdk:9.0.308-azurelinux3.0@sha256:94d5a9035229b230e05d331cbb915cd62e88867ac8fca2be693b82952147951a AS build 3 | ARG BUILD_CONFIGURATION=Release 4 | COPY [".editorconfig", ".globalconfig", "Directory.Build.props", "Directory.Packages.props", "global.json", "NuGet.config", "/example/"] 5 | COPY ["./tests/Keda.Scaler.Functions.Worker.DurableTask.Examples/", "/example/src/"] 6 | WORKDIR /example/src 7 | RUN dotnet restore "Keda.Scaler.Functions.Worker.DurableTask.Examples.csproj" 8 | RUN dotnet build "Keda.Scaler.Functions.Worker.DurableTask.Examples.csproj" -c $BUILD_CONFIGURATION -warnaserror -o /app/build 9 | 10 | FROM build AS publish 11 | ARG BUILD_CONFIGURATION=Release 12 | RUN dotnet publish "Keda.Scaler.Functions.Worker.DurableTask.Examples.csproj" -c $BUILD_CONFIGURATION -warnaserror -o /app/publish /p:UseAppHost=false 13 | 14 | FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated9.0-azurelinux3@sha256:980b5c0be24515f9cbb58f3875849de857012bccfc847e752e4b1532ec1c1bf0 AS runtime 15 | RUN chown -R $APP_UID /azure-functions-host 16 | ENV ASPNETCORE_URLS=http://+:8080 \ 17 | AzureFunctionsJobHost__FileWatchingEnabled=false \ 18 | AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ 19 | AzureFunctionsJobHost__Logging__FileLoggingMode=Never \ 20 | AzureWebJobsScriptRoot=/home/site/wwwroot \ 21 | DOTNET_EnableDiagnostics=0 \ 22 | LANG=en_US.UTF-8 \ 23 | WEBSITE_HOSTNAME=localhost:8080 24 | USER $APP_UID 25 | EXPOSE 8080 26 | 27 | FROM runtime AS func 28 | WORKDIR /home/site/wwwroot 29 | COPY --from=publish /app/publish . 30 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Gen/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/SourceGenerationContext.Int32.g.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | #nullable enable annotations 4 | #nullable disable warnings 5 | 6 | // Suppress warnings about [Obsolete] member usage in generated code. 7 | #pragma warning disable CS0612, CS0618 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Json 10 | { 11 | internal partial class SourceGenerationContext 12 | { 13 | private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? _Int32; 14 | 15 | /// 16 | /// Defines the source generated JSON serialization contract metadata for a given type. 17 | /// 18 | #nullable disable annotations // Marking the property type as nullable-oblivious. 19 | public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo Int32 20 | #nullable enable annotations 21 | { 22 | get => _Int32 ??= (global::System.Text.Json.Serialization.Metadata.JsonTypeInfo)Options.GetTypeInfo(typeof(int)); 23 | } 24 | 25 | private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo Create_Int32(global::System.Text.Json.JsonSerializerOptions options) 26 | { 27 | if (!TryGetTypeInfoForRuntimeCustomConverter(options, out global::System.Text.Json.Serialization.Metadata.JsonTypeInfo jsonTypeInfo)) 28 | { 29 | jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo(options, global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.Int32Converter); 30 | } 31 | 32 | jsonTypeInfo.OriginatingResolver = this; 33 | return jsonTypeInfo; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Interceptors/ExceptionInterceptor.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.ComponentModel.DataAnnotations; 6 | using System.Threading.Tasks; 7 | using Grpc.Core; 8 | using Grpc.Core.Interceptors; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Interceptors; 12 | 13 | internal sealed class ExceptionInterceptor(ILoggerFactory loggerFactory) : Interceptor 14 | { 15 | private readonly ILogger _logger = loggerFactory?.CreateLogger(LogCategories.Default) ?? throw new ArgumentNullException(nameof(loggerFactory)); 16 | 17 | public override async Task UnaryServerHandler( 18 | TRequest request, 19 | ServerCallContext context, 20 | UnaryServerMethod continuation) 21 | { 22 | ArgumentNullException.ThrowIfNull(request); 23 | ArgumentNullException.ThrowIfNull(context); 24 | ArgumentNullException.ThrowIfNull(continuation); 25 | 26 | try 27 | { 28 | return await continuation(request, context); 29 | } 30 | catch (ValidationException v) 31 | { 32 | _logger.ReceivedInvalidInput(v); 33 | throw new RpcException(new Status(StatusCode.InvalidArgument, v.Message)); 34 | } 35 | catch (OperationCanceledException oce) when (context.CancellationToken.IsCancellationRequested) 36 | { 37 | _logger.DetectedRequestCancellation(oce); 38 | throw new RpcException(Status.DefaultCancelled); 39 | } 40 | catch (Exception e) 41 | { 42 | _logger.CaughtUnhandledException(e); 43 | throw new RpcException(new Status(StatusCode.Internal, SR.InternalServerError)); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/MockServerCallContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Grpc.Core; 8 | using GrpcMetadata = Grpc.Core.Metadata; 9 | 10 | namespace Keda.Scaler.DurableTask.AzureStorage.Test; 11 | 12 | internal sealed class MockServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext 13 | { 14 | protected override CancellationToken CancellationTokenCore { get; } = cancellationToken; 15 | 16 | #region Unimplemented 17 | 18 | protected override string MethodCore => throw new NotImplementedException(); 19 | 20 | protected override string HostCore => throw new NotImplementedException(); 21 | 22 | protected override string PeerCore => throw new NotImplementedException(); 23 | 24 | protected override DateTime DeadlineCore => throw new NotImplementedException(); 25 | 26 | protected override GrpcMetadata RequestHeadersCore => throw new NotImplementedException(); 27 | 28 | protected override GrpcMetadata ResponseTrailersCore => throw new NotImplementedException(); 29 | 30 | protected override Status StatusCore { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } 31 | 32 | protected override WriteOptions? WriteOptionsCore { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } 33 | 34 | protected override AuthContext AuthContextCore => throw new NotImplementedException(); 35 | 36 | protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => throw new NotImplementedException(); 37 | 38 | protected override Task WriteResponseHeadersAsyncCore(GrpcMetadata responseHeaders) => throw new NotImplementedException(); 39 | 40 | #endregion 41 | } 42 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Gen/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/SourceGenerationContext.GetJsonTypeInfo.g.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | #nullable enable annotations 4 | #nullable disable warnings 5 | 6 | // Suppress warnings about [Obsolete] member usage in generated code. 7 | #pragma warning disable CS0612, CS0618 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Json 10 | { 11 | internal partial class SourceGenerationContext : global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver 12 | { 13 | /// 14 | public override global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? GetTypeInfo(global::System.Type type) 15 | { 16 | Options.TryGetTypeInfo(type, out global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? typeInfo); 17 | return typeInfo; 18 | } 19 | 20 | global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver.GetTypeInfo(global::System.Type type, global::System.Text.Json.JsonSerializerOptions options) 21 | { 22 | if (type == typeof(global::Keda.Scaler.DurableTask.AzureStorage.TaskHubs.BlobPartitionManager.AzureStorageTaskHubInfo)) 23 | { 24 | return Create_AzureStorageTaskHubInfo(options); 25 | } 26 | if (type == typeof(global::System.DateTimeOffset)) 27 | { 28 | return Create_DateTimeOffset(options); 29 | } 30 | if (type == typeof(int)) 31 | { 32 | return Create_Int32(options); 33 | } 34 | if (type == typeof(string)) 35 | { 36 | return Create_String(options); 37 | } 38 | return null; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Gen/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/SourceGenerationContext.String.g.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | #nullable enable annotations 4 | #nullable disable warnings 5 | 6 | // Suppress warnings about [Obsolete] member usage in generated code. 7 | #pragma warning disable CS0612, CS0618 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Json 10 | { 11 | internal partial class SourceGenerationContext 12 | { 13 | private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? _String; 14 | 15 | /// 16 | /// Defines the source generated JSON serialization contract metadata for a given type. 17 | /// 18 | #nullable disable annotations // Marking the property type as nullable-oblivious. 19 | public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo String 20 | #nullable enable annotations 21 | { 22 | get => _String ??= (global::System.Text.Json.Serialization.Metadata.JsonTypeInfo)Options.GetTypeInfo(typeof(string)); 23 | } 24 | 25 | private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo Create_String(global::System.Text.Json.JsonSerializerOptions options) 26 | { 27 | if (!TryGetTypeInfoForRuntimeCustomConverter(options, out global::System.Text.Json.Serialization.Metadata.JsonTypeInfo jsonTypeInfo)) 28 | { 29 | jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo(options, global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.StringConverter); 30 | } 31 | 32 | jsonTypeInfo.OriginatingResolver = this; 33 | return jsonTypeInfo; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.DurableTask.AzureStorage.Test.Integration/Keda.Scaler.DurableTask.AzureStorage.Test.Integration.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | true 6 | Exe 7 | true 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | false 11 | true 12 | Exe 13 | $(MSBuildProjectName.Substring(0, $([MSBuild]::Subtract($(MSBuildProjectName.Length), 5)))) 14 | true 15 | true 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | <_Parameter1>DynamicProxyGenAssembly2 37 | 38 | 39 | <_Parameter1>$(MSBuildProjectName).Test 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /charts/durabletask-azurestorage-scaler/Chart.yaml: -------------------------------------------------------------------------------- 1 | # Note: 2 | # Version corresponds to the chart version and should be updated every time the chart is changed. 3 | # AppVersion on the other hand should be updated every time the web app is changed. 4 | apiVersion: v2 5 | appVersion: "3.0.0" 6 | description: A KEDA external scaler for the Durable Task Azure Storage backend 7 | home: https://github.com/wsugarman/durabletask-azurestorage-scaler 8 | icon: https://raw.githubusercontent.com/wsugarman/durabletask-azurestorage-scaler/main/img/storm-icon.png 9 | keywords: 10 | - dtfx 11 | - functions 12 | - keda 13 | maintainers: 14 | - name: Will Sugarman 15 | email: will.sugarman@microsoft.com 16 | name: durabletask-azurestorage-scaler 17 | sources: 18 | - https://github.com/wsugarman/durabletask-azurestorage-scaler 19 | type: application 20 | version: "3.0.0" 21 | annotations: 22 | artifacthub.io/category: monitoring-logging 23 | artifacthub.io/changes: | 24 | - kind: added 25 | description: Added support for Partition Manager v3 using the new useTablePartitionManagement field in the ScaledObject 26 | - kind: changed 27 | description: Updated base image to mcr.microsoft.com/dotnet/aspnet:9.0.0-azurelinux3.0-distroless 28 | - kind: fixed 29 | description: Fixed possible race condition when reloading certificates that may be currently in use 30 | - kind: removed 31 | description: Removed support for AAD Pod Identity. Microsoft Entra Workload Identity is now the recommended approach 32 | artifacthub.io/containsSecurityUpdates: "false" 33 | artifacthub.io/images: | 34 | - name: durabletask-azurestorage-scaler 35 | image: ghcr.io/wsugarman/durabletask-azurestorage-scaler:3.0.0 36 | platforms: 37 | - linux/amd64 38 | artifacthub.io/license: MIT 39 | artifacthub.io/recommendations: | 40 | - url: https://artifacthub.io/packages/helm/kedacore/keda 41 | artifacthub.io/signKey: | 42 | fingerprint: 5921b98760ce1d3d6d118692d7b2b3999d8a68fe 43 | url: https://keybase.io/wsugarman/pgp_keys.asc 44 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/OptimalDurableTaskScaleManager.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Logging.Abstractions; 7 | using Microsoft.Extensions.Options; 8 | using NSubstitute; 9 | using Xunit; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 12 | 13 | public sealed class OptimalDurableTaskScaleManagerTest 14 | { 15 | private readonly ITaskHub _taskHub = Substitute.For(); 16 | private readonly TaskHubOptions _options = new() { TaskHubName = "UnitTest" }; 17 | private readonly MockOptimalScaleManager _scaleManager; 18 | 19 | public OptimalDurableTaskScaleManagerTest() 20 | { 21 | IOptionsSnapshot _optionsSnapshot = Substitute.For>(); 22 | _ = _optionsSnapshot.Get(default).Returns(_options); 23 | _scaleManager = new MockOptimalScaleManager(_taskHub, _optionsSnapshot, NullLoggerFactory.Instance); 24 | } 25 | 26 | [Theory] 27 | [InlineData(0, 3)] 28 | [InlineData(0, 1, 0, 0, 0)] 29 | [InlineData(2, 6, 1, 2, 3, 4)] 30 | [InlineData(2, 4, 3, 2, 1, 2)] 31 | [InlineData(7, 1, 5, 5, 5, 5, 5, 5, 5)] 32 | public void GivenPartitions_WhenGettingWorkerCount_ThenComputeOptimalNumber(int expected, int maxOrchestrationsPerWorker, params int[] partitions) 33 | { 34 | _options.MaxOrchestrationsPerWorker = maxOrchestrationsPerWorker; 35 | Assert.Equal(expected, _scaleManager.GetRequiredWorkerCount(new TaskHubQueueUsage(partitions, default))); 36 | } 37 | 38 | private sealed class MockOptimalScaleManager(ITaskHub taskHub, IOptionsSnapshot optionsSnapshot, ILoggerFactory loggerFactory) 39 | : OptimalDurableTaskScaleManager(taskHub, optionsSnapshot, loggerFactory) 40 | { 41 | public int GetRequiredWorkerCount(TaskHubQueueUsage usage) 42 | => GetWorkerCount(usage); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /charts/durabletask-azurestorage-scaler/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | 3 | {{/* 4 | Expand the name of the chart. 5 | */}} 6 | {{- define "durabletask-azurestorage-scaler.name" -}} 7 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 8 | {{- end }} 9 | 10 | {{/* 11 | Create a default fully qualified app name. 12 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 13 | If release name contains the word "scaler" (or if the overrien name) it will be used as a full name. 14 | */}} 15 | {{- define "durabletask-azurestorage-scaler.fullname" -}} 16 | {{- if .Values.fullnameOverride }} 17 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 18 | {{- else }} 19 | {{- $name := default "scaler" .Values.nameOverride }} 20 | {{- if contains $name .Release.Name }} 21 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 22 | {{- else }} 23 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 24 | {{- end }} 25 | {{- end }} 26 | {{- end }} 27 | 28 | {{/* 29 | Create the name of the service account to use. 30 | */}} 31 | {{- define "durabletask-azurestorage-scaler.serviceAccountName" -}} 32 | {{- default (include "durabletask-azurestorage-scaler.fullname" .) .Values.serviceAccount.name }} 33 | {{- end }} 34 | 35 | {{/* 36 | Create chart name and version as used by the chart label. 37 | */}} 38 | {{- define "durabletask-azurestorage-scaler.chart" -}} 39 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 40 | {{- end }} 41 | 42 | {{/* 43 | Common labels. 44 | */}} 45 | {{- define "durabletask-azurestorage-scaler.labels" }} 46 | helm.sh/chart: {{ include "durabletask-azurestorage-scaler.chart" . }} 47 | app.kubernetes.io/instance: {{ .Release.Name }} 48 | app.kubernetes.io/managed-by: {{ .Release.Service }} 49 | app.kubernetes.io/part-of: {{ .Chart.Name }} 50 | app.kubernetes.io/version: {{ .Chart.Version | quote }} 51 | {{- if .Values.additionalLabels }} 52 | {{ toYaml .Values.additionalLabels }} 53 | {{- end }} 54 | {{- end }} 55 | -------------------------------------------------------------------------------- /.github/actions/helm-package/action.yml: -------------------------------------------------------------------------------- 1 | name: helm package 2 | description: Packs the helm chart and optionally signs it 3 | inputs: 4 | chartName: 5 | default: durabletask-azurestorage-scaler 6 | description: The name of the Helm chart 7 | required: false 8 | chartPath: 9 | description: The path to the Helm chart 10 | required: true 11 | chartVersion: 12 | description: The version of the Helm chart 13 | required: true 14 | gpgPassword: 15 | default: '' 16 | description: The password for the GPG key 17 | required: false 18 | gpgPrivateKey: 19 | default: '' 20 | description: The Base64 GPG private signing key 21 | required: false 22 | sign: 23 | default: 'false' 24 | description: Indicates whether the helm chart should be signed 25 | required: false 26 | 27 | runs: 28 | using: composite 29 | steps: 30 | - name: Create Chart Folder 31 | shell: bash 32 | run: mkdir -p ${{ runner.temp }}/chart 33 | 34 | - name: Create Keyring 35 | shell: bash 36 | if: ${{ inputs.sign == 'true' }} 37 | run: | 38 | mkdir -p ${{ runner.temp }}/gpg 39 | echo '${{ inputs.gpgPrivateKey }}' | base64 --decode >> ${{ runner.temp }}/gpg/private-key.gpg 40 | 41 | - name: Helm Pack with Signing 42 | shell: bash 43 | if: ${{ inputs.sign == 'true' }} 44 | run: echo "${{ inputs.gpgPassword }}" | helm package ${{ inputs.chartPath }} --sign --key 'Will Sugarman' --keyring ${{ runner.temp }}/gpg/private-key.gpg --passphrase-file - 45 | working-directory: ${{ runner.temp }}/chart 46 | 47 | - name: Helm Pack 48 | shell: bash 49 | if: ${{ inputs.sign != 'true' }} 50 | run: helm package ${{ inputs.chartPath }} 51 | working-directory: ${{ runner.temp }}/chart 52 | 53 | - name: Delete Keyring 54 | shell: bash 55 | if: ${{ (inputs.sign == 'true') && always() }} 56 | run: rm ${{ runner.temp }}/gpg/private-key.gpg 57 | 58 | - name: Upload Chart 59 | uses: actions/upload-artifact@v6 60 | with: 61 | name: chart 62 | path: ${{ runner.temp }}/chart 63 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Gen/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/SourceGenerationContext.DateTimeOffset.g.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | #nullable enable annotations 4 | #nullable disable warnings 5 | 6 | // Suppress warnings about [Obsolete] member usage in generated code. 7 | #pragma warning disable CS0612, CS0618 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Json 10 | { 11 | internal partial class SourceGenerationContext 12 | { 13 | private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? _DateTimeOffset; 14 | 15 | /// 16 | /// Defines the source generated JSON serialization contract metadata for a given type. 17 | /// 18 | #nullable disable annotations // Marking the property type as nullable-oblivious. 19 | public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo DateTimeOffset 20 | #nullable enable annotations 21 | { 22 | get => _DateTimeOffset ??= (global::System.Text.Json.Serialization.Metadata.JsonTypeInfo)Options.GetTypeInfo(typeof(global::System.DateTimeOffset)); 23 | } 24 | 25 | private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo Create_DateTimeOffset(global::System.Text.Json.JsonSerializerOptions options) 26 | { 27 | if (!TryGetTypeInfoForRuntimeCustomConverter(options, out global::System.Text.Json.Serialization.Metadata.JsonTypeInfo jsonTypeInfo)) 28 | { 29 | jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo(options, global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.DateTimeOffsetConverter); 30 | } 31 | 32 | jsonTypeInfo.OriginatingResolver = this; 33 | return jsonTypeInfo; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $(MSBuildThisFileDirectory) 6 | $(RootDirectory)src/ 7 | $(RootDirectory)tests/ 8 | 9 | 10 | 11 | 12 | Copyright © 2024 William Sugarman. 13 | false 14 | Durable Task KEDA External Scaler 15 | 16 | 17 | 18 | 19 | true 20 | true 21 | latest 22 | true 23 | enable 24 | net9.0 25 | true 26 | 27 | 28 | 29 | 30 | 31 | latest-All 32 | true 33 | true 34 | 35 | 36 | 37 | 38 | 39 | <_Parameter1>false 40 | <_Parameter1_TypeName>System.Boolean 41 | 42 | 43 | <_Parameter1>en 44 | <_Parameter1_TypeName>System.String 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | $(NoWarn);CA1707;CA2007;CS1591 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Metadata/IServiceCollection.Extensions.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Linq; 6 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Options; 9 | using Xunit; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Metadata; 12 | 13 | public class IServiceCollectionExtensionsTest 14 | { 15 | private readonly ServiceCollection _servicCollection = new(); 16 | 17 | [Fact] 18 | public void GivenNullServiceCollection_WhenAdddingAzureStorageServiceClients_ThenThrowArgumentNullException() 19 | => Assert.Throws(() => IServiceCollectionExtensions.AddScalerMetadata(null!)); 20 | 21 | [Fact] 22 | public void GivenServiceCollection_WhenAddingAzureStorageServiceClients_ThenRegisterServices() 23 | { 24 | IServiceCollection services = _servicCollection.AddScalerMetadata(); 25 | 26 | ServiceDescriptor accessor = Assert.Single(services, x => x.ServiceType == typeof(IScalerMetadataAccessor)); 27 | Assert.Equal(ServiceLifetime.Scoped, accessor.Lifetime); 28 | Assert.Equal(typeof(ScalerMetadataAccessor), accessor.ImplementationType); 29 | 30 | ServiceDescriptor configure = Assert.Single(services, x => x.ServiceType == typeof(IConfigureOptions)); 31 | Assert.Equal(ServiceLifetime.Scoped, configure.Lifetime); 32 | Assert.Equal(typeof(ConfigureScalerOptions), configure.ImplementationType); 33 | 34 | ServiceDescriptor[] validators = services.Where(x => x.ServiceType == typeof(IValidateOptions)).ToArray(); 35 | Assert.Equal(2, validators.Length); 36 | Assert.Equal(ServiceLifetime.Singleton, validators[0].Lifetime); 37 | Assert.Equal(typeof(ValidateTaskHubScalerOptions), validators[0].ImplementationType); 38 | Assert.Equal(ServiceLifetime.Singleton, validators[1].Lifetime); 39 | Assert.Equal(typeof(ValidateScalerOptions), validators[1].ImplementationType); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/IConfiguration.Extensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Diagnostics; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 10 | 11 | internal static class IConfigurationExtensions 12 | { 13 | public static bool IsTlsEnforced(this IConfiguration configuration) 14 | { 15 | ArgumentNullException.ThrowIfNull(configuration); 16 | Debug.Assert(configuration is not IConfigurationSection); 17 | 18 | // Note: We use the configuration-based Kestrel settings as a proxy for whether 19 | // TLS is enabled as that is the only way it is configured via Helm 20 | return !string.IsNullOrWhiteSpace(configuration.GetSection("Kestrel:Certificates:Default:Path").Value); 21 | } 22 | 23 | public static bool UseCustomClientCa(this IConfiguration configuration) 24 | { 25 | if (!configuration.IsTlsEnforced()) 26 | return false; 27 | 28 | ClientCertificateValidationOptions options = configuration.GetCertificateValidationOptions(); 29 | return options.Enable && options.CertificateAuthority is not null; 30 | } 31 | 32 | public static bool ValidateClientCertificate(this IConfiguration configuration) 33 | => configuration.IsTlsEnforced() && configuration.GetCertificateValidationOptions().Enable; 34 | 35 | private static ClientCertificateValidationOptions GetCertificateValidationOptions(this IConfiguration configuration) 36 | { 37 | ClientCertificateValidationOptions options = new(); 38 | configuration.GetSection(ClientCertificateValidationOptions.DefaultKey).Bind(options); 39 | ValidateOptionsResult result = ValidateClientCertificateValidationOptions.Instance.Validate(Options.DefaultName, options); 40 | if (result.Failed) 41 | throw new OptionsValidationException(Options.DefaultName, typeof(ClientCertificateValidationOptions), result.Failures); 42 | 43 | return options; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/SR.Formats.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System.Globalization; 5 | using System.Text; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage; 8 | 9 | internal static class SRF 10 | { 11 | /// 12 | public static CompositeFormat EmptyOrWhiteSpace { get; } = CompositeFormat.Parse(SR.EmptyOrWhiteSpaceFormat); 13 | 14 | /// 15 | public static CompositeFormat FileNotFound { get; } = CompositeFormat.Parse(SR.FileNotFoundFormat); 16 | 17 | /// 18 | public static CompositeFormat InvalidConnectionEnvironmentVariable { get; } = CompositeFormat.Parse(SR.InvalidConnectionEnvironmentVariableFormat); 19 | 20 | /// 21 | public static CompositeFormat InvalidMemberTypeFormat { get; } = CompositeFormat.Parse(SR.InvalidMemberTypeFormat); 22 | 23 | /// 24 | public static CompositeFormat MissingPrivateCloudProperty { get; } = CompositeFormat.Parse(SR.MissingPrivateCloudPropertyFormat); 25 | 26 | /// 27 | public static CompositeFormat PrivateCloudOnlyProperty { get; } = CompositeFormat.Parse(SR.PrivateCloudOnlyPropertyFormat); 28 | 29 | /// 30 | public static CompositeFormat ServiceUriOnlyProperty { get; } = CompositeFormat.Parse(SR.ServiceUriOnlyPropertyFormat); 31 | 32 | /// 33 | public static CompositeFormat UnknownCloudValue { get; } = CompositeFormat.Parse(SR.UnknownCloudValueFormat); 34 | 35 | public static string Format(CompositeFormat format, TArg0 arg0) 36 | => string.Format(CultureInfo.CurrentCulture, format, arg0); 37 | 38 | public static string Format(CompositeFormat format, TArg0 arg0, TArg1 arg1) 39 | => string.Format(CultureInfo.CurrentCulture, format, arg0, arg1); 40 | } 41 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.DurableTask.AzureStorage.Test.Integration/ScaleTestOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.ComponentModel.DataAnnotations; 7 | using System.Diagnostics.CodeAnalysis; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Integration; 10 | 11 | [SuppressMessage("Microsoft.Performance", "CA1812:Avoid uninstantiated internal classes.", Justification = "This class is instantiated via dependency injection.")] 12 | internal sealed class ScaleTestOptions : IValidatableObject 13 | { 14 | public const string DefaultSectionName = "Scaling"; 15 | 16 | [Range(typeof(TimeSpan), "00:00:00", "00:05:00", ConvertValueInInvariantCulture = true, ParseLimitsInInvariantCulture = true)] 17 | public TimeSpan ActivityDuration { get; set; } = TimeSpan.FromMinutes(1); 18 | 19 | [Range(typeof(TimeSpan), "00:00:01", "00:05:00", ConvertValueInInvariantCulture = true, ParseLimitsInInvariantCulture = true)] 20 | public TimeSpan LoggingInterval { get; set; } = TimeSpan.FromSeconds(60); 21 | 22 | [Range(1, int.MaxValue)] 23 | public int MaxActivitiesPerWorker { get; set; } = 10; 24 | 25 | [Range(0, int.MaxValue)] 26 | public int MinReplicas { get; set; } = 0; 27 | 28 | [Range(typeof(TimeSpan), "00:00:01", "00:00:10", ConvertValueInInvariantCulture = true, ParseLimitsInInvariantCulture = true)] 29 | public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(2); 30 | 31 | public int PollingIntervalsPerLog => (int)LoggingInterval.TotalSeconds / (int)PollingInterval.TotalSeconds; 32 | 33 | [Range(typeof(TimeSpan), "00:00:30", "00:10:00", ConvertValueInInvariantCulture = true, ParseLimitsInInvariantCulture = true)] 34 | public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(2); 35 | 36 | IEnumerable IValidatableObject.Validate(ValidationContext validationContext) 37 | { 38 | if (LoggingInterval.TotalSeconds % PollingInterval.TotalSeconds != 0) 39 | yield return new ValidationResult("Polling interval must be a multiple of the logging interval."); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/scaler-pr.yml: -------------------------------------------------------------------------------- 1 | name: Scaler PR 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths-ignore: 7 | - '**.md' 8 | 9 | jobs: 10 | validate: 11 | name: Validate 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v6 17 | 18 | - name: Read Versions 19 | id: chart 20 | uses: ./.github/actions/parse-chart 21 | with: 22 | workflowRunId: ${{ github.run_id }} 23 | 24 | - name: Run Unit Tests 25 | uses: ./.github/actions/dotnet-publish 26 | with: 27 | assemblyVersion: ${{ steps.chart.outputs.assemblyVersion }} 28 | buildConfiguration: Release 29 | coverageFileName: coverage.cobertura.xml 30 | fileVersion: ${{ steps.chart.outputs.assemblyFileVersion }} 31 | sign: 'false' 32 | testResultsDirectory: ${{ runner.temp }}/TestResults 33 | 34 | - name: Upload Code Coverage 35 | uses: ./.github/actions/code-coverage 36 | with: 37 | codecovToken: ${{ secrets.CODECOV_TOKEN }} 38 | reportPath: ${{ runner.temp }}/TestResults/coverage.cobertura.xml 39 | 40 | - name: Build Docker Image 41 | uses: ./.github/actions/docker-build 42 | with: 43 | assemblyVersion: ${{ steps.chart.outputs.assemblyVersion }} 44 | buildConfiguration: Release 45 | copyBinaries: 'false' 46 | fileVersion: ${{ steps.chart.outputs.assemblyFileVersion }} 47 | imageRepository: durabletask-azurestorage-scaler 48 | imageTag: ${{ steps.chart.outputs.imageTag }} 49 | 50 | - name: Validate Helm Chart 51 | uses: ./.github/actions/helm-test 52 | with: 53 | buildConfiguration: Release 54 | imageTag: ${{ steps.chart.outputs.imageTag }} 55 | scalerImageRepository: durabletask-azurestorage-scaler 56 | 57 | - name: Pack Helm Chart 58 | uses: ./.github/actions/helm-package 59 | with: 60 | chartPath: ${{ github.workspace }}/charts/durabletask-azurestorage-scaler 61 | chartVersion: ${{ steps.chart.outputs.chartVersion }} 62 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/TablePartitionManager.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Net; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Azure; 11 | using Azure.Data.Tables; 12 | using Microsoft.Extensions.Logging; 13 | using Microsoft.Extensions.Options; 14 | 15 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 16 | 17 | internal sealed class TablePartitionManager(TableServiceClient tableServiceClient, IOptionsSnapshot options, ILoggerFactory loggerFactory) : ITaskHubPartitionManager 18 | { 19 | private readonly TableServiceClient _tableServiceClient = tableServiceClient ?? throw new ArgumentNullException(nameof(tableServiceClient)); 20 | private readonly TaskHubOptions _options = options?.Get(default) ?? throw new ArgumentNullException(nameof(options)); 21 | private readonly ILogger _logger = loggerFactory?.CreateLogger(LogCategories.Default) ?? throw new ArgumentNullException(nameof(loggerFactory)); 22 | 23 | public async ValueTask> GetPartitionsAsync(CancellationToken cancellationToken = default) 24 | { 25 | // Query the table for partitions 26 | TableClient client = _tableServiceClient.GetTableClient(PartitionsTable.GetName(_options.TaskHubName)); 27 | 28 | List partitions; 29 | try 30 | { 31 | partitions = await client 32 | .QueryAsync(select: [nameof(TableEntity.RowKey)], cancellationToken: cancellationToken) 33 | .Select(x => x.RowKey) 34 | .ToListAsync(cancellationToken); 35 | } 36 | catch (RequestFailedException rfe) when (rfe.Status is (int)HttpStatusCode.NotFound) 37 | { 38 | partitions = []; 39 | } 40 | 41 | if (partitions.Count > 0) 42 | _logger.FoundTaskHubPartitionsTable(_options.TaskHubName, partitions.Count, client.Name); 43 | else 44 | _logger.CannotFindTaskHubPartitionsTable(_options.TaskHubName, client.Name); 45 | 46 | return partitions; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/IServiceCollection.Extensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Azure.Data.Tables; 6 | using Azure.Storage.Blobs; 7 | using Azure.Storage.Queues; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 12 | 13 | internal static class IServiceCollectionExtensions 14 | { 15 | public static IServiceCollection AddAzureStorageServiceClients(this IServiceCollection services) 16 | { 17 | ArgumentNullException.ThrowIfNull(services); 18 | 19 | return services 20 | .AddOptions() 21 | .AddSingleton() 22 | .AddSingleton() 23 | .AddSingleton() 24 | .AddScoped, ConfigureAzureStorageAccountOptions>() 25 | .AddScoped, ValidateAzureStorageAccountOptions>() 26 | .AddScoped(sp => GetBlobServiceClient(sp.GetRequiredService(), sp.GetRequiredService>())) 27 | .AddScoped(sp => GetQueueServiceClient(sp.GetRequiredService(), sp.GetRequiredService>())) 28 | .AddScoped(sp => GetTableServiceClient(sp.GetRequiredService(), sp.GetRequiredService>())); 29 | } 30 | 31 | private static BlobServiceClient GetBlobServiceClient(BlobServiceClientFactory factory, IOptionsSnapshot options) 32 | => factory.GetServiceClient(options.Get(default)); 33 | 34 | private static QueueServiceClient GetQueueServiceClient(QueueServiceClientFactory factory, IOptionsSnapshot options) 35 | => factory.GetServiceClient(options.Get(default)); 36 | 37 | private static TableServiceClient GetTableServiceClient(TableServiceClientFactory factory, IOptionsSnapshot options) 38 | => factory.GetServiceClient(options.Get(default)); 39 | } 40 | -------------------------------------------------------------------------------- /charts/azurite/templates/01-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: {{ template "azurite.fullname" . }} 6 | name: {{ template "azurite.fullname" . }} 7 | app.kubernetes.io/component: storage 8 | app.kubernetes.io/name: {{ template "azurite.fullname" . }} 9 | {{- include "azurite.labels" . | indent 4 }} 10 | name: {{ template "azurite.fullname" . }} 11 | namespace: {{ .Release.Namespace }} 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: {{ template "azurite.fullname" . }} 16 | replicas: 1 17 | template: 18 | metadata: 19 | labels: 20 | app: {{ template "azurite.fullname" . }} 21 | name: {{ template "azurite.fullname" . }} 22 | app.kubernetes.io/component: storage 23 | app.kubernetes.io/name: {{ template "azurite.fullname" . }} 24 | {{- include "azurite.labels" . | indent 8 }} 25 | spec: 26 | {{- with .Values.podSecurityContext }} 27 | securityContext: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 33 | imagePullPolicy: {{ .Values.image.pullPolicy }} 34 | {{- with .Values.securityContext }} 35 | securityContext: 36 | {{- toYaml . | nindent 12 }} 37 | {{- end }} 38 | command: 39 | - azurite 40 | - -l 41 | - /data 42 | - --blobHost 43 | - "0.0.0.0" 44 | - --queueHost 45 | - "0.0.0.0" 46 | - --tableHost 47 | - "0.0.0.0" 48 | - --loose 49 | - --disableProductStyleUrl 50 | {{- if .Values.debug.enable }} 51 | - --debug 52 | - /data/debug.log 53 | {{- end }} 54 | {{- with .Values.resources }} 55 | resources: 56 | {{- toYaml .| nindent 12 }} 57 | {{- end }} 58 | volumeMounts: 59 | - name: data 60 | mountPath: /data 61 | volumes: 62 | - name: data 63 | emptyDir: 64 | {{- if .Values.storage.limit }} 65 | sizeLimit: {{ .Values.storage.limit }} 66 | {{- end }} 67 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG COPY=false 2 | 3 | # See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. 4 | FROM mcr.microsoft.com/dotnet/sdk:9.0.308-azurelinux3.0@sha256:94d5a9035229b230e05d331cbb915cd62e88867ac8fca2be693b82952147951a AS build 5 | ARG BUILD_CONFIGURATION=Release 6 | ARG CONTINUOUS_INTEGRATION_BUILD=false 7 | ARG ASSEMBLY_VERSION=1.0.0 8 | ARG FILE_VERSION=1.0.0.0 9 | 10 | COPY [".editorconfig", ".globalconfig", "Directory.Build.props", "Directory.Packages.props", "global.json", "NuGet.config", "/scaler/"] 11 | COPY ["./src/Directory.Build.props", "./src/Keda.Scaler.DurableTask.AzureStorage/", "/scaler/src/"] 12 | WORKDIR /scaler/src 13 | RUN dotnet restore "Keda.Scaler.DurableTask.AzureStorage.csproj" 14 | RUN dotnet build "Keda.Scaler.DurableTask.AzureStorage.csproj" \ 15 | -c $BUILD_CONFIGURATION \ 16 | "-p:ContinuousIntegrationBuild=$CONTINUOUS_INTEGRATION_BUILD;AssemblyVersion=$ASSEMBLY_VERSION;FileVersion=$FILE_VERSION;InformationalVersion=$FILE_VERSION" \ 17 | -warnaserror \ 18 | -o /app/build 19 | 20 | FROM build AS publish 21 | ARG BUILD_CONFIGURATION=Release 22 | ARG CONTINUOUS_INTEGRATION_BUILD=false 23 | ARG ASSEMBLY_VERSION=1.0.0 24 | ARG FILE_VERSION=1.0.0.0 25 | 26 | RUN dotnet publish "Keda.Scaler.DurableTask.AzureStorage.csproj" \ 27 | -c $BUILD_CONFIGURATION \ 28 | "-p:ContinuousIntegrationBuild=$CONTINUOUS_INTEGRATION_BUILD;AssemblyVersion=$ASSEMBLY_VERSION;FileVersion=$FILE_VERSION;InformationalVersion=$FILE_VERSION" \ 29 | -warnaserror \ 30 | -o /app/publish 31 | 32 | FROM scratch AS publish-copy-false 33 | COPY --from=publish /app/publish /app 34 | 35 | # Optionally, binaries can be taken directly from the Docker build context 36 | FROM scratch AS publish-copy-true 37 | ARG PUBLISH_DIRECTORY 38 | COPY ${PUBLISH_DIRECTORY} /app 39 | 40 | FROM publish-copy-${COPY} AS publish-app 41 | 42 | FROM mcr.microsoft.com/dotnet/aspnet:9.0.11-azurelinux3.0-distroless@sha256:9021fc6705ea71b00c19c619ef2c521a3707f872be45f943268d4545754a6a51 AS runtime 43 | ENV ASPNETCORE_URLS=http://+:8080 \ 44 | DOTNET_EnableDiagnostics=0 \ 45 | DOTNET_USE_POLLING_FILE_WATCHER=true 46 | USER $APP_UID 47 | EXPOSE 8080 48 | 49 | FROM runtime AS web 50 | COPY --from=publish-app /app /app 51 | WORKDIR /app 52 | ENTRYPOINT ["dotnet", "Keda.Scaler.DurableTask.AzureStorage.dll"] 53 | -------------------------------------------------------------------------------- /src/Scaler.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.1.31911.260 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{9944A29F-262F-44BE-BA5C-A3CDCCCE5543}" 6 | ProjectSection(SolutionItems) = preProject 7 | ..\.editorconfig = ..\.editorconfig 8 | ..\.globalconfig = ..\.globalconfig 9 | CodeCoverage.runsettings = CodeCoverage.runsettings 10 | Directory.Build.props = Directory.Build.props 11 | ..\Directory.Build.props = ..\Directory.Build.props 12 | ..\Directory.Packages.props = ..\Directory.Packages.props 13 | ..\global.json = ..\global.json 14 | EndProjectSection 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Keda.Scaler.DurableTask.AzureStorage", "Keda.Scaler.DurableTask.AzureStorage\Keda.Scaler.DurableTask.AzureStorage.csproj", "{1536D38D-D145-4BA4-BEB8-39E1A11DECBE}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Keda.Scaler.DurableTask.AzureStorage.Test", "Keda.Scaler.DurableTask.AzureStorage.Test\Keda.Scaler.DurableTask.AzureStorage.Test.csproj", "{926704CC-0E9B-445A-A1F6-D7816BA59599}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 26 | {1536D38D-D145-4BA4-BEB8-39E1A11DECBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {1536D38D-D145-4BA4-BEB8-39E1A11DECBE}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {1536D38D-D145-4BA4-BEB8-39E1A11DECBE}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {1536D38D-D145-4BA4-BEB8-39E1A11DECBE}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {926704CC-0E9B-445A-A1F6-D7816BA59599}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {926704CC-0E9B-445A-A1F6-D7816BA59599}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {926704CC-0E9B-445A-A1F6-D7816BA59599}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {926704CC-0E9B-445A-A1F6-D7816BA59599}.Release|Any CPU.Build.0 = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(ExtensibilityGlobals) = postSolution 39 | SolutionGuid = {FD5C70A3-C248-4A20-856C-CF38A2CDDEDA} 40 | EndGlobalSection 41 | EndGlobal 42 | -------------------------------------------------------------------------------- /tests/ScaleTests.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.4.33205.214 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{C9370BFD-71C3-4A7B-949B-2770E40B3CBD}" 7 | ProjectSection(SolutionItems) = preProject 8 | ..\.dockerignore = ..\.dockerignore 9 | ..\.editorconfig = ..\.editorconfig 10 | ..\.globalconfig = ..\.globalconfig 11 | ..\Directory.Build.props = ..\Directory.Build.props 12 | ..\Directory.Packages.props = ..\Directory.Packages.props 13 | ..\global.json = ..\global.json 14 | EndProjectSection 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Keda.Scaler.DurableTask.AzureStorage.Test.Integration", "Keda.Scaler.DurableTask.AzureStorage.Test.Integration\Keda.Scaler.DurableTask.AzureStorage.Test.Integration.csproj", "{3F20A11E-9764-4225-9D9C-59BC3A02C50F}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Keda.Scaler.Functions.Worker.DurableTask.Examples", "Keda.Scaler.Functions.Worker.DurableTask.Examples\Keda.Scaler.Functions.Worker.DurableTask.Examples.csproj", "{2FB12C53-50C8-4C4C-9417-5FD8B01545AD}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 26 | {3F20A11E-9764-4225-9D9C-59BC3A02C50F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {3F20A11E-9764-4225-9D9C-59BC3A02C50F}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {3F20A11E-9764-4225-9D9C-59BC3A02C50F}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {3F20A11E-9764-4225-9D9C-59BC3A02C50F}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {2FB12C53-50C8-4C4C-9417-5FD8B01545AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {2FB12C53-50C8-4C4C-9417-5FD8B01545AD}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {2FB12C53-50C8-4C4C-9417-5FD8B01545AD}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {2FB12C53-50C8-4C4C-9417-5FD8B01545AD}.Release|Any CPU.Build.0 = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(ExtensibilityGlobals) = postSolution 39 | SolutionGuid = {34A0BD1F-303E-46ED-86B8-410694D42341} 40 | EndGlobalSection 41 | EndGlobal 42 | -------------------------------------------------------------------------------- /src/CodeCoverage.runsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | .*Keda\.Scaler\.DurableTask\.AzureStorage\.dll$ 13 | 14 | 15 | 16 | 17 | 18 | 19 | ^System\.Diagnostics\.DebuggerHiddenAttribute$ 20 | ^System\.Diagnostics\.DebuggerNonUserCodeAttribute$ 21 | ^System\.CodeDom\.Compiler\.GeneratedCodeAttribute$ 22 | ^System\.Diagnostics\.CodeAnalysis\.ExcludeFromCodeCoverageAttribute$ 23 | 24 | 25 | 26 | 27 | 28 | 29 | .*g\.cs$ 30 | .*Program\.cs$ 31 | .*Protos.*\.cs$ 32 | 33 | 34 | 35 | True 36 | False 37 | True 38 | True 39 | True 40 | True 41 | True 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/TaskHubs/ConfigureTaskHubOptions.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 6 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 7 | using Microsoft.Extensions.Options; 8 | using NSubstitute; 9 | using Xunit; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.TaskHubs; 12 | 13 | public class ConfigureTaskHubOptionsTest 14 | { 15 | private readonly ScalerOptions _scalerOptions = new(); 16 | private readonly ConfigureTaskHubOptions _configure; 17 | 18 | public ConfigureTaskHubOptionsTest() 19 | { 20 | IOptionsSnapshot snapshot = Substitute.For>(); 21 | _ = snapshot.Get(default).Returns(_scalerOptions); 22 | _configure = new(snapshot); 23 | } 24 | 25 | [Fact] 26 | public void GivenNullOptionsSnapshot_WhenCreatingConfigure_ThenThrowArgumentNullException() 27 | { 28 | _ = Assert.Throws(() => new ConfigureTaskHubOptions(null!)); 29 | 30 | IOptionsSnapshot nullSnapshot = Substitute.For>(); 31 | _ = nullSnapshot.Get(default).Returns(default(ScalerOptions)); 32 | _ = Assert.Throws(() => new ConfigureTaskHubOptions(nullSnapshot)); 33 | } 34 | 35 | [Fact] 36 | public void GivenNullOptions_WhenConfiguring_ThenThrowArgumentNullException() 37 | => Assert.Throws(() => _configure.Configure(null!)); 38 | 39 | [Fact] 40 | public void GivenScalerMetadata_WhenConfiguring_ThenCopyProperties() 41 | { 42 | _scalerOptions.MaxActivitiesPerWorker = 17; 43 | _scalerOptions.MaxOrchestrationsPerWorker = 5; 44 | _scalerOptions.TaskHubName = "UnitTest"; 45 | _scalerOptions.UseTablePartitionManagement = true; 46 | 47 | TaskHubOptions actual = new(); 48 | _configure.Configure(actual); 49 | 50 | Assert.Equal(_scalerOptions.MaxActivitiesPerWorker, actual.MaxActivitiesPerWorker); 51 | Assert.Equal(_scalerOptions.MaxOrchestrationsPerWorker, actual.MaxOrchestrationsPerWorker); 52 | Assert.Equal(_scalerOptions.TaskHubName, actual.TaskHubName); 53 | Assert.Equal(_scalerOptions.UseTablePartitionManagement, actual.UseTablePartitionManagement); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Keda.Scaler.DurableTask.AzureStorage.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Linux 6 | $(RootDirectory) 7 | true 8 | true 9 | Gen 10 | false 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | True 40 | True 41 | SR.resx 42 | 43 | 44 | SR.resx 45 | 46 | 47 | 48 | 49 | 50 | ResXFileCodeGenerator 51 | SR.Designer.cs 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /.github/actions/docker-build/action.yml: -------------------------------------------------------------------------------- 1 | name: docker test 2 | description: Builds the scaler docker image 3 | inputs: 4 | assemblyVersion: 5 | default: '1.0.0' 6 | description: The scaler assembly's version. 7 | required: false 8 | buildConfiguration: 9 | default: Debug 10 | description: The dotnet build configuration. 11 | required: false 12 | copyBinaries: 13 | default: 'false' 14 | description: Indicates whether the dockerfile should build the scaler or simply copy existing binaries. 15 | required: false 16 | fileVersion: 17 | default: '1.0.0' 18 | description: The scaler assembly's file version. 19 | required: false 20 | imageRepository: 21 | default: durabletask-azurestorage-scaler 22 | description: The repository used for the scaler image. 23 | required: false 24 | imageTag: 25 | description: The tag to use for the images. 26 | required: true 27 | 28 | runs: 29 | using: composite 30 | steps: 31 | - if: ${{ inputs.copyBinaries == 'true' }} 32 | name: Download Scaler Binaries 33 | uses: actions/download-artifact@v7 34 | with: 35 | name: app 36 | path: ./app 37 | 38 | - name: Generate OCI Labels 39 | id: meta 40 | uses: docker/metadata-action@v5 41 | with: 42 | images: | 43 | ${{ inputs.imageRepository }} 44 | tags: | 45 | type=semver,pattern={{version}},value=v${{ inputs.imageTag }} 46 | 47 | - name: Build Scaler Image 48 | uses: docker/build-push-action@v6 49 | with: 50 | build-args: | 51 | ASSEMBLY_VERSION=${{ inputs.assemblyVersion }} 52 | BUILD_CONFIGURATION=${{ inputs.buildConfiguration }} 53 | CONTINUOUS_INTEGRATION_BUILD=true 54 | COPY=${{ inputs.copyBinaries }} 55 | FILE_VERSION=${{ inputs.fileVersion }} 56 | PUBLISH_DIRECTORY=./app 57 | context: . 58 | file: ./src/Keda.Scaler.DurableTask.AzureStorage/Dockerfile 59 | labels: ${{ steps.meta.outputs.labels }} 60 | push: false 61 | tags: ${{ steps.meta.outputs.tags }} 62 | 63 | - name: Save Image 64 | shell: bash 65 | run: | 66 | output="${{ runner.temp }}/image/durabletask-azurestorage-scaler-${{ inputs.imageTag }}.tar" 67 | mkdir -p $(dirname $output) 68 | docker save -o $output ${{ inputs.imageRepository }}:${{ inputs.imageTag }} 69 | 70 | - name: Upload Image 71 | uses: actions/upload-artifact@v6 72 | with: 73 | name: image 74 | path: ${{ runner.temp }}/image/durabletask-azurestorage-scaler-${{ inputs.imageTag }}.tar 75 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/ConfigureAzureStorageAccountOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Azure.Identity; 6 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 10 | 11 | internal sealed class ConfigureAzureStorageAccountOptions(IOptionsSnapshot scalerOptions) : IConfigureOptions 12 | { 13 | private readonly ScalerOptions _scalerOptions = scalerOptions?.Get(default) ?? throw new ArgumentNullException(nameof(scalerOptions)); 14 | 15 | public void Configure(AzureStorageAccountOptions options) 16 | { 17 | ArgumentNullException.ThrowIfNull(options); 18 | 19 | if (_scalerOptions.AccountName is null) 20 | ConfigureStringBasedConnection(options); 21 | else 22 | ConfigureUriBasedConnection(options); 23 | } 24 | 25 | private void ConfigureStringBasedConnection(AzureStorageAccountOptions options) 26 | => options.ConnectionString = _scalerOptions.Connection ?? Environment.GetEnvironmentVariable(_scalerOptions.ConnectionFromEnv ?? AzureStorageAccountOptions.DefaultConnectionEnvironmentVariable, EnvironmentVariableTarget.Process); 27 | 28 | private void ConfigureUriBasedConnection(AzureStorageAccountOptions options) 29 | { 30 | options.AccountName = _scalerOptions.AccountName; 31 | 32 | if (AzureCloudEndpoints.TryParseEnvironment(_scalerOptions.Cloud, out CloudEnvironment cloud)) 33 | { 34 | AzureCloudEndpoints endpoints = cloud is CloudEnvironment.Private 35 | ? new AzureCloudEndpoints(_scalerOptions.EntraEndpoint!, _scalerOptions.EndpointSuffix!) 36 | : AzureCloudEndpoints.ForEnvironment(cloud); 37 | 38 | options.EndpointSuffix = endpoints.StorageSuffix; 39 | options.TokenCredential = CreateTokenCredential(endpoints.AuthorityHost); 40 | } 41 | } 42 | 43 | private WorkloadIdentityCredential? CreateTokenCredential(Uri authorityHost) 44 | { 45 | if (_scalerOptions.UseManagedIdentity) 46 | { 47 | WorkloadIdentityCredentialOptions options = new() 48 | { 49 | AuthorityHost = authorityHost, 50 | }; 51 | 52 | if (!string.IsNullOrWhiteSpace(_scalerOptions.ClientId)) 53 | options.ClientId = _scalerOptions.ClientId; 54 | 55 | return new WorkloadIdentityCredential(options); 56 | } 57 | 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Clients/AzureStorageServiceUri.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 6 | using Xunit; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Clients; 9 | 10 | public class AzureStorageServiceUriTest 11 | { 12 | [Fact] 13 | public void GivenNullAccountName_WhenAzureStorageServiceUri_ThenThrowArgumentNullException() 14 | => Assert.Throws(() => AzureStorageServiceUri.Create(null!, AzureStorageService.Blob, AzureStorageServiceUri.PublicSuffix)); 15 | 16 | [Theory] 17 | [InlineData("")] 18 | [InlineData(" \t\r\n")] 19 | public void GivenEmptyOrWhiteSpaceAccountName_WhenAzureStorageServiceUri_ThenThrowArgumentException(string accountName) 20 | => Assert.Throws(() => AzureStorageServiceUri.Create(accountName, AzureStorageService.Blob, AzureStorageServiceUri.PublicSuffix)); 21 | 22 | [Fact] 23 | public void GivenNullEndpointSuffix_WhenAzureStorageServiceUri_ThenThrowArgumentNullException() 24 | => Assert.Throws(() => AzureStorageServiceUri.Create("unittest", AzureStorageService.Blob, null!)); 25 | 26 | [Theory] 27 | [InlineData("")] 28 | [InlineData(" \t\r\n")] 29 | public void GivenEmptyOrWhiteSpaceEndpointSuffix_WhenAzureStorageServiceUri_ThenThrowArgumentException(string endpointSuffix) 30 | => Assert.Throws(() => AzureStorageServiceUri.Create("unittest", AzureStorageService.Blob, endpointSuffix)); 31 | 32 | [Fact] 33 | public void GivenUnknownService_WhenGettingStorageServiceUri_ThenThrowArgumentOutOfRangeException() 34 | => Assert.Throws(() => AzureStorageServiceUri.Create("unittest", (AzureStorageService)42, AzureStorageServiceUri.PublicSuffix)); 35 | 36 | [Theory] 37 | [InlineData("https://foo.blob.core.windows.net", "foo", AzureStorageService.Blob, AzureStorageServiceUri.PublicSuffix)] 38 | [InlineData("https://bar.queue.core.chinacloudapi.cn", "bar", AzureStorageService.Queue, AzureStorageServiceUri.ChinaSuffix)] 39 | [InlineData("https://baz.table.core.usgovcloudapi.net", "baz", AzureStorageService.Table, AzureStorageServiceUri.USGovernmentSuffix)] 40 | public void GivenStorageAccount_WhenGettingStorageServiceUri_ThenReturnExpectedValue(string expected, string accountName, AzureStorageService service, string endpointSuffix) 41 | => Assert.Equal(new Uri(expected, UriKind.Absolute), AzureStorageServiceUri.Create(accountName, service, endpointSuffix)); 42 | } 43 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Clients/AzureStorageAccountClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Azure.Core; 6 | 7 | namespace Keda.Scaler.DurableTask.AzureStorage.Clients; 8 | 9 | /// 10 | /// Represents a factory for creating Azure Storage service clients based on the context for a given request. 11 | /// 12 | /// The type of the service client. 13 | public abstract class AzureStorageAccountClientFactory 14 | { 15 | /// 16 | /// Gets the Azure Storage service associated with produced clients. 17 | /// 18 | /// 19 | /// , , 20 | /// or . 21 | /// 22 | protected abstract AzureStorageService Service { get; } 23 | 24 | /// 25 | /// Creates a service client based on the given account options. 26 | /// 27 | /// The options associated with the current request. 28 | /// The corresponding Azure Storage service client. 29 | /// is . 30 | public T GetServiceClient(AzureStorageAccountOptions options) 31 | { 32 | ArgumentNullException.ThrowIfNull(options); 33 | 34 | if (string.IsNullOrWhiteSpace(options.ConnectionString)) 35 | { 36 | Uri serviceUri = AzureStorageServiceUri.Create(options.AccountName!, Service, options.EndpointSuffix!); 37 | return CreateServiceClient(serviceUri, options.TokenCredential!); 38 | } 39 | else 40 | { 41 | return CreateServiceClient(options.ConnectionString); 42 | } 43 | } 44 | 45 | /// 46 | /// Creates a service client based on the given connection string. 47 | /// 48 | /// An Azure Storage connection string. 49 | /// The corresponding Azure Storage service client. 50 | protected abstract T CreateServiceClient(string connectionString); 51 | 52 | /// 53 | /// Creates a service client based on the given service URI. 54 | /// 55 | /// An Azure Storage service URI. 56 | /// A token credential for authenticating the client. 57 | /// The corresponding Azure Storage service client. 58 | protected abstract T CreateServiceClient(Uri serviceUri, TokenCredential credential); 59 | } 60 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Clients/TableQueueServiceClientFactory.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Azure.Core; 6 | using System.Reflection; 7 | using Azure.Data.Tables; 8 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 9 | using Xunit; 10 | using Azure.Core.Pipeline; 11 | using System.Linq; 12 | 13 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Clients; 14 | 15 | public class TableServiceClientFactoryTest : AzureStorageAccountClientFactoryTest 16 | { 17 | protected override AzureStorageAccountClientFactory GetFactory() 18 | => new TableServiceClientFactory(); 19 | 20 | protected override void ValidateAccountName(TableServiceClient actual, string accountName, string endpointSuffix) 21 | => Validate(actual, accountName, AzureStorageServiceUri.Create(accountName, AzureStorageService.Table, endpointSuffix)); 22 | 23 | protected override void ValidateEmulator(TableServiceClient actual) 24 | => Validate(actual, "devstoreaccount1", new Uri("http://127.0.0.1:10002/devstoreaccount1", UriKind.Absolute)); 25 | 26 | protected override void AssertTokenCredential(TableServiceClient client) 27 | { 28 | Type cacheType = typeof(BearerTokenAuthenticationPolicy) 29 | .GetNestedTypes(BindingFlags.NonPublic) 30 | .Single(x => x.FullName == "Azure.Core.Pipeline.BearerTokenAuthenticationPolicy+AccessTokenCache"); 31 | 32 | HttpPipeline pipeline = Assert.IsType(typeof(TableServiceClient) 33 | .GetField("_pipeline", BindingFlags.NonPublic | BindingFlags.Instance)? 34 | .GetValue(client)); 35 | ReadOnlyMemory pipelineMemory = Assert.IsType>(typeof(HttpPipeline) 36 | .GetField("_pipeline", BindingFlags.NonPublic | BindingFlags.Instance)? 37 | .GetValue(pipeline)); 38 | object? tokenCache = typeof(BearerTokenAuthenticationPolicy) 39 | .GetField("_accessTokenCache", BindingFlags.NonPublic | BindingFlags.Instance)? 40 | .GetValue(pipelineMemory 41 | .ToArray() 42 | .Single(x => x.GetType().IsAssignableTo(typeof(BearerTokenAuthenticationPolicy)))); 43 | 44 | Assert.NotNull(tokenCache); 45 | TokenCredential? tokenCredential = cacheType 46 | .GetField("_credential", BindingFlags.NonPublic | BindingFlags.Instance)? 47 | .GetValue(tokenCache) as TokenCredential; 48 | 49 | _ = Assert.IsType(tokenCredential); 50 | } 51 | 52 | private static void Validate(TableServiceClient actual, string accountName, Uri serviceUrl) 53 | { 54 | Assert.Equal(accountName, actual?.AccountName); 55 | Assert.Equal(serviceUrl, actual?.Uri); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using Keda.Scaler.DurableTask.AzureStorage.Certificates; 5 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 6 | using Keda.Scaler.DurableTask.AzureStorage.Interceptors; 7 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 8 | using Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 9 | using Keda.Scaler.DurableTask.AzureStorage.Web; 10 | using Microsoft.AspNetCore.Builder; 11 | using Microsoft.AspNetCore.Hosting; 12 | using Microsoft.Extensions.DependencyInjection; 13 | 14 | const string PolicyName = "default"; 15 | 16 | WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); 17 | 18 | // Additional configuration is required to successfully run gRPC on macOS. 19 | // For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682 20 | 21 | // Add services to the container 22 | builder.Services 23 | .AddScalerMetadata() 24 | .AddAzureStorageServiceClients() 25 | .AddDurableTaskScaleManager() 26 | .AddMutualTlsSupport(PolicyName, builder.Configuration) 27 | .AddGrpc(o => 28 | { 29 | o.Interceptors.Add(); 30 | o.Interceptors.Add(); 31 | }); 32 | 33 | #if DEBUG 34 | // Note: gRPC reflection is only used for debugging, and as such it will not be included 35 | // in the final build artifact copied into the scaler image 36 | builder.Services.AddGrpcReflection(); 37 | #endif 38 | 39 | // Ensure that the client certificate validation defers to the athentication 40 | // provided by Microsoft.AspNetCore.Authentication.Certificate. All other settings 41 | // related to Kestrel will be specified via the configuration object 42 | _ = builder.WebHost 43 | .UseKestrelHttpsConfiguration() 44 | .ConfigureKestrel(k => k.ConfigureHttpsDefaults(h => h.AllowAnyClientCertificate())); 45 | 46 | // Build the web app and update its middleware pipeline 47 | WebApplication app = builder.Build(); 48 | 49 | // Configure the HTTP request pipeline 50 | if (app.Configuration.ValidateClientCertificate()) 51 | { 52 | _ = app 53 | .UseMiddleware() 54 | .UseAuthentication(); 55 | } 56 | 57 | GrpcServiceEndpointConventionBuilder grpcBuilder = app.MapGrpcService(); 58 | if (app.Configuration.ValidateClientCertificate()) 59 | _ = grpcBuilder.RequireAuthorization(PolicyName); 60 | 61 | #if DEBUG 62 | // The following routes and services should only be available when debugging the scaler 63 | app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); 64 | app.MapGrpcReflectionService(); 65 | #endif 66 | 67 | app.Run(); 68 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/TaskHubQueueUsage.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 9 | 10 | /// 11 | /// Represents the current activity in the Azure Storage queues used by a Durable Task Hub 12 | /// with the Azure Storage backend provider. 13 | /// 14 | public sealed class TaskHubQueueUsage 15 | { 16 | /// 17 | /// Gets the approximate number of messages per control queue partition. 18 | /// 19 | /// 20 | /// The i-th element represents partition i. 21 | /// 22 | /// A list of the control queue message counts. 23 | public IReadOnlyList ControlQueueMessages { get; } 24 | 25 | /// 26 | /// Gets a value indicating whether there is currently any activity for the Task Hub. 27 | /// 28 | /// 29 | /// if there is at least one message that is pending; otherwise, . 30 | /// 31 | public bool HasActivity => WorkItemQueueMessages > 0 || ControlQueueMessages.Any(x => x > 0); 32 | 33 | /// 34 | /// Gets the approximate number of messages in the work item queue. 35 | /// 36 | /// The number of work item messages. 37 | public int WorkItemQueueMessages { get; } 38 | 39 | /// 40 | /// Gets a object that represents no activity. 41 | /// 42 | /// An object representing no activity. 43 | public static TaskHubQueueUsage None { get; } = new TaskHubQueueUsage([], 0); 44 | 45 | /// 46 | /// Initializes a new instance of the class. 47 | /// 48 | /// The approximate number of messages per control queue partition. 49 | /// The approximate number of messages in the work item queue. 50 | /// is . 51 | /// is less than 0. 52 | public TaskHubQueueUsage(IReadOnlyList controlQueueMessages, int workItemQueueMessages) 53 | { 54 | ArgumentNullException.ThrowIfNull(controlQueueMessages); 55 | ArgumentOutOfRangeException.ThrowIfNegative(workItemQueueMessages); 56 | foreach (int count in controlQueueMessages) 57 | ArgumentOutOfRangeException.ThrowIfNegative(count, nameof(controlQueueMessages)); 58 | 59 | ControlQueueMessages = controlQueueMessages; 60 | WorkItemQueueMessages = workItemQueueMessages; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/IServiceCollection.Extensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.Threading; 7 | using Microsoft.AspNetCore.Authentication.Certificate; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Options; 11 | 12 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 13 | 14 | [ExcludeFromCodeCoverage(Justification = "Tested in CI via Helm")] 15 | internal static class IServiceCollectionExtensions 16 | { 17 | public static IServiceCollection AddMutualTlsSupport(this IServiceCollection services, string policyName, IConfiguration configuration) 18 | { 19 | ArgumentNullException.ThrowIfNull(services); 20 | ArgumentException.ThrowIfNullOrWhiteSpace(policyName); 21 | ArgumentNullException.ThrowIfNull(configuration); 22 | 23 | _ = services 24 | .AddSingleton, ValidateClientCertificateValidationOptions>() 25 | .AddOptions() 26 | .BindConfiguration(ClientCertificateValidationOptions.DefaultKey); 27 | 28 | _ = services 29 | .AddOptions(CertificateAuthenticationDefaults.AuthenticationScheme) 30 | .Configure>((dest, src) => dest.RevocationMode = src.Value.RevocationMode); 31 | 32 | _ = services 33 | .AddOptions() 34 | .BindConfiguration(ClientCertificateValidationOptions.DefaultCachingKey); 35 | 36 | if (configuration.ValidateClientCertificate()) 37 | { 38 | _ = services 39 | .AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) 40 | .AddCertificate() 41 | .AddCertificateCache(); 42 | 43 | _ = services 44 | .AddAuthorization(o => o 45 | .AddPolicy(policyName, b => b 46 | .AddAuthenticationSchemes(CertificateAuthenticationDefaults.AuthenticationScheme) 47 | .RequireAuthenticatedUser())); 48 | 49 | if (configuration.UseCustomClientCa()) 50 | { 51 | _ = services 52 | .AddSingleton(sp => new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion)) 53 | .AddSingleton() 54 | .AddSingleton>(p => p.GetRequiredService()) 55 | .AddSingleton>(p => p.GetRequiredService()); 56 | } 57 | } 58 | 59 | return services; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Keda.Scaler.DurableTask.AzureStorage.Test.Integration/Log.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Microsoft.DurableTask.Client; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Integration; 9 | 10 | internal static partial class Log 11 | { 12 | [LoggerMessage( 13 | EventId = 1, 14 | Level = LogLevel.Information, 15 | Message = "Started '{Orchestration}' instance '{InstanceId}'.")] 16 | public static partial void StartedOrchestration(this ILogger logger, string orchestration, string instanceId); 17 | 18 | [LoggerMessage( 19 | EventId = 2, 20 | Level = LogLevel.Information, 21 | Message = "Waiting for instance '{InstanceId}' to complete.")] 22 | public static partial void WaitingForOrchestration(this ILogger logger, string instanceId); 23 | 24 | [LoggerMessage( 25 | EventId = 3, 26 | Level = LogLevel.Information, 27 | Message = "Current status of instance '{InstanceId}' is '{Status}'.")] 28 | public static partial void ObservedOrchestrationStatus(this ILogger logger, string instanceId, OrchestrationRuntimeStatus status); 29 | 30 | [LoggerMessage( 31 | EventId = 4, 32 | Level = LogLevel.Information, 33 | Message = "Instance '{InstanceId}' reached terminal status '{Status}'.")] 34 | public static partial void ObservedOrchestrationCompletion(this ILogger logger, string instanceId, OrchestrationRuntimeStatus? status); 35 | 36 | [LoggerMessage( 37 | EventId = 5, 38 | Level = LogLevel.Information, 39 | Message = "Waiting for scale down to {Target} replicas.")] 40 | public static partial void MonitoringWorkerScaleDown(this ILogger logger, int target); 41 | 42 | [LoggerMessage( 43 | EventId = 6, 44 | Level = LogLevel.Information, 45 | Message = "Waiting for scale up to at least {Target} replicas.")] 46 | public static partial void MonitoringWorkerScaleUp(this ILogger logger, int target); 47 | 48 | [LoggerMessage( 49 | EventId = 7, 50 | Level = LogLevel.Information, 51 | Message = "Current scale for deployment '{Deployment}' in namespace '{Namespace}' is {Status}/{Spec}...")] 52 | public static partial void ObservedKubernetesDeploymentScale(this ILogger logger, string deployment, string @namespace, int status, int spec); 53 | 54 | [LoggerMessage( 55 | EventId = 8, 56 | Level = LogLevel.Information, 57 | Message = "Terminated instance '{InstanceId}.'")] 58 | public static partial void TerminatedOrchestration(this ILogger logger, string instanceId); 59 | 60 | [LoggerMessage( 61 | EventId = 9, 62 | Level = LogLevel.Warning, 63 | Message = "Error encountered when terminating instance '{InstanceId}.'")] 64 | public static partial void FailedTerminatingOrchestration(this ILogger logger, Exception exception, string instanceId); 65 | } 66 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/BlobPartitionManager.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.ComponentModel.DataAnnotations; 7 | using System.Linq; 8 | using System.Net; 9 | using System.Text.Json; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using Azure; 13 | using Azure.Storage.Blobs; 14 | using Azure.Storage.Blobs.Models; 15 | using Keda.Scaler.DurableTask.AzureStorage.Json; 16 | using Microsoft.Extensions.Logging; 17 | using Microsoft.Extensions.Options; 18 | 19 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 20 | 21 | internal sealed class BlobPartitionManager(BlobServiceClient blobServiceClient, IOptionsSnapshot options, ILoggerFactory loggerFactory) : ITaskHubPartitionManager 22 | { 23 | private readonly BlobServiceClient _blobServiceClient = blobServiceClient ?? throw new ArgumentNullException(nameof(blobServiceClient)); 24 | private readonly TaskHubOptions _options = options?.Get(default) ?? throw new ArgumentNullException(nameof(options)); 25 | private readonly ILogger _logger = loggerFactory?.CreateLogger(LogCategories.Default) ?? throw new ArgumentNullException(nameof(loggerFactory)); 26 | 27 | public async ValueTask> GetPartitionsAsync(CancellationToken cancellationToken = default) 28 | { 29 | // Fetch the blob 30 | BlobClient client = _blobServiceClient 31 | .GetBlobContainerClient(LeasesContainer.GetName(_options.TaskHubName)) 32 | .GetBlobClient(LeasesContainer.TaskHubBlobName); 33 | 34 | AzureStorageTaskHubInfo? info; 35 | try 36 | { 37 | BlobDownloadResult result = await client.DownloadContentAsync(cancellationToken); 38 | 39 | // Parse the information about the task hub 40 | info = JsonSerializer.Deserialize( 41 | result.Content.ToMemory().Span, 42 | SourceGenerationContext.Default.AzureStorageTaskHubInfo); 43 | } 44 | catch (RequestFailedException rfe) when (rfe.Status is (int)HttpStatusCode.NotFound) 45 | { 46 | info = null; 47 | } 48 | 49 | if (info is null) 50 | { 51 | _logger.CannotFindTaskHubBlob(_options.TaskHubName, LeasesContainer.TaskHubBlobName, client.BlobContainerName); 52 | return []; 53 | } 54 | else 55 | { 56 | _logger.FoundTaskHubBlob(info.TaskHubName, info.PartitionCount, info.CreatedAt, LeasesContainer.TaskHubBlobName); 57 | return Enumerable 58 | .Repeat(info.TaskHubName, info.PartitionCount) 59 | .Select((t, i) => ControlQueue.GetName(info.TaskHubName, i)) 60 | .ToList(); 61 | } 62 | } 63 | 64 | internal sealed class AzureStorageTaskHubInfo 65 | { 66 | [Required] 67 | public DateTimeOffset CreatedAt { get; init; } 68 | 69 | [Range(1, 15)] 70 | public int PartitionCount { get; init; } 71 | 72 | [Required] 73 | public string TaskHubName { get; init; } = default!; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/scaler-ci-image.yml: -------------------------------------------------------------------------------- 1 | name: Scaler Image CD 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: windows-latest 9 | outputs: 10 | imagePrerelease: ${{ steps.chart.outputs.imagePrerelease }} 11 | imageTag: ${{ steps.chart.outputs.imageTag }} 12 | 13 | permissions: 14 | id-token: write 15 | 16 | steps: 17 | - name: Update Git Config 18 | run: git config --system core.longpaths true 19 | 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | 23 | - name: Read Versions 24 | id: chart 25 | uses: ./.github/actions/parse-chart 26 | 27 | - name: Build Scaler 28 | uses: ./.github/actions/dotnet-publish 29 | with: 30 | assemblyVersion: ${{ steps.chart.outputs.assemblyVersion }} 31 | azureClientId: ${{ secrets.AZURE_CLIENT_ID }} 32 | azureCodeSigningAccountName: ${{ secrets.AZURE_CODESIGNING_NAME }} 33 | azureSubscriptionId: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 34 | azureTenantId: ${{ secrets.AZURE_TENANT_ID }} 35 | buildConfiguration: Release 36 | certificateProfileName: ${{ secrets.AZURE_CODESIGNING_PROFILE_NAME }} 37 | coverageFileName: coverage.cobertura.xml 38 | fileVersion: ${{ steps.chart.outputs.assemblyFileVersion }} 39 | repositoryUri: ${{ github.server_url }}/${{ github.repository }} 40 | sign: 'true' 41 | testResultsDirectory: ${{ runner.temp }}/TestResults 42 | 43 | - name: Upload Code Coverage 44 | uses: ./.github/actions/code-coverage 45 | with: 46 | codecovToken: ${{ secrets.CODECOV_TOKEN }} 47 | reportPath: ${{ runner.temp }}/TestResults/coverage.cobertura.xml 48 | 49 | publishImage: 50 | name: Publish Image 51 | runs-on: ubuntu-latest 52 | needs: build 53 | permissions: 54 | contents: write 55 | packages: write 56 | 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v6 60 | 61 | - name: Build Docker Image 62 | uses: ./.github/actions/docker-build 63 | with: 64 | copyBinaries: 'true' 65 | imageRepository: ghcr.io/${{ github.repository }} 66 | imageTag: ${{ needs.build.outputs.imageTag }} 67 | 68 | - name: Push Docker Image 69 | id: push 70 | uses: ./.github/actions/docker-push 71 | with: 72 | force: 'true' 73 | gitHubToken: ${{ secrets.GITHUB_TOKEN }} 74 | imageRepository: ghcr.io/${{ github.repository }} 75 | imageTag: ${{ needs.build.outputs.imageTag }} 76 | pushLatest: ${{ needs.build.outputs.imagePrerelease != 'true' }} 77 | 78 | - name: Create Image Release 79 | uses: ./.github/actions/github-release 80 | with: 81 | asset: ${{ runner.temp }}/image/durabletask-azurestorage-scaler-${{ needs.build.outputs.imageTag }}.tar 82 | name: 'Durable Task KEDA External Scaler Image' 83 | prerelease: ${{ needs.build.outputs.imagePrerelease }} 84 | tag: 'Image_${{ needs.build.outputs.imageTag }}.${{ steps.push.outputs.digest }}' 85 | version: '${{ needs.build.outputs.imageTag }}.${{ steps.push.outputs.digest }}' 86 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Metadata/ConfigureScalerOptions.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Google.Protobuf.Collections; 7 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 8 | using NSubstitute; 9 | using Xunit; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Metadata; 12 | 13 | public class ConfigureScalerOptionsTest 14 | { 15 | private readonly IScalerMetadataAccessor _metadataAccessor = Substitute.For(); 16 | private readonly ConfigureScalerOptions _configure; 17 | 18 | public ConfigureScalerOptionsTest() 19 | => _configure = new(_metadataAccessor); 20 | 21 | [Fact] 22 | public void GivenNullScalerMetadataAccessor_WhenCreatingConfigure_ThenThrowArgumentNullException() 23 | => Assert.Throws(() => new ConfigureScalerOptions(null!)); 24 | 25 | [Fact] 26 | public void GivenMissingScalerMetadata_WhenConfiguringOptions_ThenThrowInvalidOperationException() 27 | { 28 | _ = _metadataAccessor.ScalerMetadata.Returns(default(IReadOnlyDictionary)); 29 | _ = Assert.Throws(() => _configure.Configure(new ScalerOptions())); 30 | } 31 | 32 | [Fact] 33 | public void GivenScalerMetadata_WhenConfiguringOptions_ThenParseFields() 34 | { 35 | MapField metadata = new() 36 | { 37 | { nameof(ScalerOptions.AccountName), "AccountName" }, 38 | { nameof(ScalerOptions.ClientId), "ClientId" }, 39 | { nameof(ScalerOptions.Cloud), "Cloud" }, 40 | { nameof(ScalerOptions.Connection), "Connection" }, 41 | { nameof(ScalerOptions.ConnectionFromEnv), "ConnectionFromEnv" }, 42 | { "endpointsuffix", "EndpointSuffix" }, 43 | { "ENTRAENDPOINT", "https://unit.test.login/" }, 44 | { nameof(ScalerOptions.MaxActivitiesPerWorker), "1" }, 45 | { nameof(ScalerOptions.MaxOrchestrationsPerWorker), "2" }, 46 | { nameof(ScalerOptions.TaskHubName), "TaskHubName" }, 47 | { nameof(ScalerOptions.UseManagedIdentity), "true" }, 48 | { nameof(ScalerOptions.UseTablePartitionManagement), "false" }, 49 | }; 50 | _ = _metadataAccessor.ScalerMetadata.Returns(metadata); 51 | 52 | ScalerOptions options = new(); 53 | _configure.Configure(options); 54 | 55 | Assert.Equal("AccountName", options.AccountName); 56 | Assert.Equal("ClientId", options.ClientId); 57 | Assert.Equal("Cloud", options.Cloud); 58 | Assert.Equal("Connection", options.Connection); 59 | Assert.Equal("ConnectionFromEnv", options.ConnectionFromEnv); 60 | Assert.Equal("EndpointSuffix", options.EndpointSuffix); 61 | Assert.Equal("https://unit.test.login/", options.EntraEndpoint?.AbsoluteUri); 62 | Assert.Equal(1, options.MaxActivitiesPerWorker); 63 | Assert.Equal(2, options.MaxOrchestrationsPerWorker); 64 | Assert.Equal("TaskHubName", options.TaskHubName); 65 | Assert.True(options.UseManagedIdentity); 66 | Assert.False(options.UseTablePartitionManagement); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /charts/example-function-app/templates/02-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: {{ template "example-function-app.fullname" . }} 6 | name: {{ template "example-function-app.fullname" . }} 7 | app.kubernetes.io/component: functionapp 8 | app.kubernetes.io/name: {{ template "example-function-app.fullname" . }} 9 | {{- include "example-function-app.labels" . | indent 4 }} 10 | name: {{ template "example-function-app.fullname" . }} 11 | namespace: {{ .Release.Namespace }} 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: {{ template "example-function-app.fullname" . }} 16 | replicas: {{ .Values.scaledObject.minReplicaCount }} 17 | {{- with .Values.upgradeStrategy }} 18 | strategy: 19 | {{- toYaml . | nindent 4 }} 20 | {{- end }} 21 | template: 22 | metadata: 23 | labels: 24 | app: {{ template "example-function-app.fullname" . }} 25 | name: {{ template "example-function-app.fullname" . }} 26 | app.kubernetes.io/component: functionapp 27 | app.kubernetes.io/name: {{ template "example-function-app.fullname" . }} 28 | {{- include "example-function-app.labels" . | indent 8 }} 29 | spec: 30 | {{- with .Values.podSecurityContext }} 31 | securityContext: 32 | {{- toYaml . | nindent 8 }} 33 | {{- end }} 34 | containers: 35 | - name: {{ .Chart.Name }} 36 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 37 | imagePullPolicy: {{ .Values.image.pullPolicy }} 38 | {{- with .Values.securityContext }} 39 | securityContext: 40 | {{- toYaml . | nindent 12 }} 41 | {{- end }} 42 | env: 43 | - name: AzureWebJobsStorage 44 | value: {{ .Values.taskHub.connectionString }} 45 | - name: AzureFunctionsJobHost__Extensions__DurableTask__HubName 46 | value: {{ .Values.taskHub.name }} 47 | - name: AzureFunctionsJobHost__Extensions__DurableTask__MaxConcurrentActivityFunctions 48 | value: {{ .Values.taskHub.maxActivitiesPerWorker | quote }} 49 | - name: AzureFunctionsJobHost__Extensions__DurableTask__MaxConcurrentOrchestratorFunctions 50 | value: {{ .Values.taskHub.maxOrchestrationsPerWorker | quote }} 51 | - name: AzureFunctionsJobHost__Extensions__DurableTask__StorageProvider__PartitionCount 52 | value: {{ .Values.taskHub.partitionCount | quote }} 53 | {{- with .Values.resources }} 54 | resources: 55 | {{- toYaml . | nindent 12 }} 56 | {{- end }} 57 | startupProbe: 58 | failureThreshold: 3 59 | httpGet: 60 | path: /api/healthz 61 | port: 8080 62 | scheme: HTTP 63 | periodSeconds: 10 64 | successThreshold: 1 65 | timeoutSeconds: 5 66 | volumeMounts: 67 | - name: secrets 68 | mountPath: /azure-functions-host/Secrets 69 | - name: tmp 70 | mountPath: /tmp/Functions 71 | serviceAccountName: {{ template "example-function-app.fullname" . }} 72 | volumes: 73 | - name: secrets 74 | emptyDir: {} 75 | - name: tmp 76 | emptyDir: {} 77 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/DataAnnotations/FileExistsAttribute.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.IO; 7 | using Keda.Scaler.DurableTask.AzureStorage.DataAnnotations; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Options; 10 | using Xunit; 11 | 12 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.DataAnnotations; 13 | 14 | public sealed class FileExistsAttributeTests : IDisposable 15 | { 16 | private readonly string _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); 17 | 18 | public FileExistsAttributeTests() 19 | => Directory.CreateDirectory(_tempPath); 20 | 21 | [Fact] 22 | public void GivenIncorrectMemberType_WhenValidatingFileExists_ThenThrowOptionsValidationException() 23 | { 24 | ServiceCollection services = new(); 25 | _ = services 26 | .AddOptions() 27 | .Configure(o => o.Number = 42) 28 | .ValidateDataAnnotations(); 29 | 30 | using ServiceProvider provider = services.BuildServiceProvider(); 31 | _ = Assert.Throws(() => provider.GetRequiredService>().Value); 32 | } 33 | 34 | [Fact] 35 | public void GivenMissingFile_WhenValidatingFileExists_ThenThrowOptionsValidationException() 36 | { 37 | ServiceCollection services = new(); 38 | _ = services 39 | .AddOptions() 40 | .Configure(o => o.FilePath = Path.Combine(_tempPath, Guid.NewGuid().ToString())) 41 | .ValidateDataAnnotations(); 42 | 43 | using ServiceProvider provider = services.BuildServiceProvider(); 44 | _ = Assert.Throws(() => provider.GetRequiredService>().Value); 45 | } 46 | 47 | [Fact] 48 | public void GivenPresentFile_WhenValidatingFileExists_ThenDoNotThrowException() 49 | { 50 | string filePath = Path.Combine(_tempPath, "file.txt"); 51 | File.WriteAllText(filePath, "Hello, World!"); 52 | 53 | ServiceCollection services = new(); 54 | _ = services 55 | .AddOptions() 56 | .Configure(o => o.FilePath = filePath) 57 | .ValidateDataAnnotations(); 58 | 59 | using ServiceProvider provider = services.BuildServiceProvider(); 60 | ExampleOptions actual = provider.GetRequiredService>().Value; 61 | Assert.Equal(filePath, actual.FilePath); 62 | } 63 | 64 | public void Dispose() 65 | => Directory.Delete(_tempPath, true); 66 | 67 | [SuppressMessage("Microsoft.Performance", "CA1812:Avoid uninstantiated internal classes.", Justification = "Initialized via dependency injection.")] 68 | private sealed class InvalidExampleOptions 69 | { 70 | [FileExists] 71 | public int Number { get; set; } = 1; 72 | } 73 | 74 | [SuppressMessage("Microsoft.Performance", "CA1812:Avoid uninstantiated internal classes.", Justification = "Initialized via dependency injection.")] 75 | private sealed class ExampleOptions 76 | { 77 | [FileExists] 78 | public string? FilePath { get; set; } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Clients/AzureStorageAccountClientFactory.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Linq; 6 | using System.Reflection; 7 | using Azure.Core; 8 | using Azure.Identity; 9 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 10 | using Xunit; 11 | 12 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Clients; 13 | 14 | public abstract class AzureStorageAccountClientFactoryTest 15 | { 16 | [Fact] 17 | public void GivenNullAccountInfo_WhenGettingServiceClient_ThenThrowArgumentNullException() 18 | { 19 | AzureStorageAccountClientFactory factory = GetFactory(); 20 | _ = Assert.Throws(() => factory.GetServiceClient(null!)); 21 | } 22 | 23 | [Fact] 24 | public void GivenConnectionString_WhenGettingServiceClient_ThenReturnValidClient() 25 | { 26 | AzureStorageAccountClientFactory factory = GetFactory(); 27 | TClient actual = factory.GetServiceClient(new AzureStorageAccountOptions { ConnectionString = "UseDevelopmentStorage=true" }); 28 | ValidateEmulator(actual); 29 | } 30 | 31 | [Fact] 32 | public void GivenServiceUri_WhenGettingServiceClient_ThenReturnValidClient() 33 | { 34 | AzureStorageAccountClientFactory factory = GetFactory(); 35 | AzureStorageAccountOptions storageAccountInfo = new() 36 | { 37 | AccountName = "test", 38 | ConnectionString = null, 39 | EndpointSuffix = AzureStorageServiceUri.PublicSuffix, 40 | TokenCredential = new WorkloadIdentityCredential( 41 | new WorkloadIdentityCredentialOptions 42 | { 43 | ClientId = Guid.NewGuid().ToString(), 44 | TenantId = Guid.NewGuid().ToString(), 45 | TokenFilePath = "/token.txt", 46 | }), 47 | }; 48 | 49 | TClient actual = factory.GetServiceClient(storageAccountInfo); 50 | ValidateAccountName(actual, "test", AzureStorageServiceUri.PublicSuffix); 51 | AssertTokenCredential(actual); 52 | } 53 | 54 | // Note: We use a method here to avoid exposing the internal implementation classes 55 | protected abstract AzureStorageAccountClientFactory GetFactory(); 56 | 57 | protected abstract void ValidateAccountName(TClient actual, string accountName, string endpointSuffix); 58 | 59 | protected abstract void ValidateEmulator(TClient actual); 60 | 61 | protected virtual void AssertTokenCredential(TClient client) where T : TokenCredential 62 | { 63 | object? configuration = typeof(TClient) 64 | .GetProperty("ClientConfiguration", BindingFlags.NonPublic | BindingFlags.Instance)? 65 | .GetValue(client); 66 | 67 | Assert.NotNull(configuration); 68 | TokenCredential? tokenCredential = typeof(TClient).Assembly 69 | .DefinedTypes 70 | .Single(x => x.FullName == "Azure.Storage.Shared.StorageClientConfiguration") 71 | .GetProperty("TokenCredential", BindingFlags.Public | BindingFlags.Instance)? 72 | .GetValue(configuration) as TokenCredential; 73 | 74 | Assert.NotNull(tokenCredential); 75 | _ = Assert.IsType(tokenCredential); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Interceptors/ExceptionInterceptor.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.ComponentModel.DataAnnotations; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Grpc.Core; 9 | using Keda.Scaler.DurableTask.AzureStorage.Interceptors; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Logging.Abstractions; 12 | using NSubstitute; 13 | using Xunit; 14 | 15 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Interceptors; 16 | 17 | public sealed class ExceptionInterceptorTest 18 | { 19 | private readonly ExceptionInterceptor _interceptor = new(NullLoggerFactory.Instance); 20 | 21 | [Fact] 22 | public void GivenNullLoggerFactory_WhenCreatingInterceptor_ThenThrowArgumentNullException() 23 | => Assert.Throws(() => new ExceptionInterceptor(null!)); 24 | 25 | [Fact] 26 | public void GivenNullLogger_WhenCreatingInterceptor_ThenThrowArgumentNullException() 27 | { 28 | ILoggerFactory factory = Substitute.For(); 29 | _ = factory.CreateLogger(default!).ReturnsForAnyArgs((ILogger)null!); 30 | 31 | _ = Assert.Throws(() => new ExceptionInterceptor(factory)); 32 | } 33 | 34 | [Theory] 35 | [InlineData(StatusCode.InvalidArgument, typeof(ValidationException), false)] 36 | [InlineData(StatusCode.InvalidArgument, typeof(ValidationException), true)] 37 | [InlineData(StatusCode.Cancelled, typeof(TaskCanceledException), true)] 38 | [InlineData(StatusCode.Cancelled, typeof(OperationCanceledException), true)] 39 | [InlineData(StatusCode.Internal, typeof(OperationCanceledException), false)] 40 | [InlineData(StatusCode.Internal, typeof(NullReferenceException), false)] 41 | [InlineData(StatusCode.Internal, typeof(OutOfMemoryException), true)] 42 | public async ValueTask GivenCaughtException_WhenHandlingUnaryRequest_ThenThrowRpcException(StatusCode expected, Type exceptionType, bool canceled) 43 | { 44 | using CancellationTokenSource tokenSource = new(); 45 | if (canceled) 46 | await tokenSource.CancelAsync(); 47 | 48 | Request request = new(); 49 | MockServerCallContext context = new(tokenSource.Token); 50 | 51 | RpcException actual = await Assert.ThrowsAsync(() => _interceptor.UnaryServerHandler(request, context, GetContinuation())); 52 | Assert.Equal(expected, actual.Status.StatusCode); 53 | 54 | UnaryServerMethod GetContinuation() 55 | => (Request r, ServerCallContext c) => Task.FromException((Exception)Activator.CreateInstance(exceptionType)!); 56 | } 57 | 58 | [Fact] 59 | public async ValueTask GivenNoException_WhenHandlingUnaryRequest_ThenReturnContinuation() 60 | { 61 | Request request = new(); 62 | Response response = new(); 63 | MockServerCallContext context = new(); 64 | 65 | Assert.Same(response, await _interceptor.UnaryServerHandler(request, context, GetContinuation())); 66 | 67 | UnaryServerMethod GetContinuation() 68 | => (Request r, ServerCallContext c) => Task.FromResult(response); 69 | } 70 | 71 | private sealed class Request 72 | { } 73 | 74 | private sealed class Response 75 | { } 76 | } 77 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/TaskHubs/TaskHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Net; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Azure; 10 | using Azure.Storage.Queues; 11 | using Azure.Storage.Queues.Models; 12 | using Microsoft.Extensions.Logging; 13 | using Microsoft.Extensions.Options; 14 | 15 | namespace Keda.Scaler.DurableTask.AzureStorage.TaskHubs; 16 | 17 | internal class TaskHub(ITaskHubPartitionManager partitionManager, QueueServiceClient queueServiceClient, IOptionsSnapshot options, ILoggerFactory loggerFactory) : ITaskHub 18 | { 19 | private readonly ITaskHubPartitionManager _partitionManager = partitionManager ?? throw new ArgumentNullException(nameof(partitionManager)); 20 | private readonly QueueServiceClient _queueServiceClient = queueServiceClient ?? throw new ArgumentNullException(nameof(queueServiceClient)); 21 | private readonly TaskHubOptions _options = options?.Get(default) ?? throw new ArgumentNullException(nameof(options)); 22 | private readonly ILogger _logger = loggerFactory?.CreateLogger(LogCategories.Default) ?? throw new ArgumentNullException(nameof(loggerFactory)); 23 | 24 | public virtual async ValueTask GetUsageAsync(CancellationToken cancellationToken = default) 25 | { 26 | IReadOnlyList partitionIds = await _partitionManager.GetPartitionsAsync(cancellationToken); 27 | if (partitionIds.Count is 0) 28 | return TaskHubQueueUsage.None; 29 | 30 | int workItemQueueMessages; 31 | int[] controlQueueMessages = new int[partitionIds.Count]; 32 | 33 | // Look at the Control Queues to determine the number of active partitions 34 | for (int i = 0; i < partitionIds.Count; i++) 35 | { 36 | QueueClient controlQueueClient = _queueServiceClient.GetQueueClient(partitionIds[i]); 37 | 38 | try 39 | { 40 | QueueProperties properties = await controlQueueClient.GetPropertiesAsync(cancellationToken); 41 | controlQueueMessages[i] = properties.ApproximateMessagesCount; 42 | } 43 | catch (RequestFailedException rfe) when (rfe.Status is (int)HttpStatusCode.NotFound) 44 | { 45 | _logger.CouldNotFindControlQueue(controlQueueClient.Name); 46 | return TaskHubQueueUsage.None; 47 | } 48 | } 49 | 50 | // Look at the Work Item queue to determine the number of active activities, events, etc 51 | QueueClient workItemQueueClient = _queueServiceClient.GetQueueClient(WorkItemQueue.GetName(_options.TaskHubName)); 52 | 53 | try 54 | { 55 | QueueProperties properties = await workItemQueueClient.GetPropertiesAsync(cancellationToken); 56 | workItemQueueMessages = properties.ApproximateMessagesCount; 57 | } 58 | catch (RequestFailedException rfe) when (rfe.Status is (int)HttpStatusCode.NotFound) 59 | { 60 | _logger.CouldNotFindWorkItemQueue(workItemQueueClient.Name); 61 | return TaskHubQueueUsage.None; 62 | } 63 | 64 | _logger.FoundTaskHubQueues(workItemQueueMessages, string.Join(", ", controlQueueMessages)); 65 | return new TaskHubQueueUsage(controlQueueMessages, workItemQueueMessages); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /.github/actions/github-release/scripts/CreateRelease.mjs: -------------------------------------------------------------------------------- 1 | const cp = await import('node:child_process'); 2 | const fs = await import('node:fs'); 3 | const path = await import('node:path'); 4 | 5 | export default async function createRelease({ github, context, release }) { 6 | try { 7 | // Try to get the tag 8 | await github.rest.git.getRef({ 9 | owner: context.repo.owner, 10 | repo: context.repo.repo, 11 | ref: `tags/${release.tag}` 12 | }); 13 | 14 | console.log(`Tag ${release.tag} already exists.`); 15 | return; 16 | } 17 | catch (httpError) { 18 | // A missing tag will throw a 404 19 | if (httpError.status == 404) { 20 | console.log(`Tag ${release.tag} does not yet exist.`); 21 | } else { 22 | throw httpError; 23 | } 24 | } 25 | 26 | // Create the tag for the release 27 | const commit = await github.rest.git.getCommit({ 28 | owner: context.repo.owner, 29 | repo: context.repo.repo, 30 | commit_sha: context.sha 31 | }); 32 | 33 | await github.rest.git.createTag({ 34 | owner: context.repo.owner, 35 | repo: context.repo.repo, 36 | tag: release.tag, 37 | message: `${release.name} Version ${release.version}`, 38 | object: context.sha, 39 | type: 'commit', 40 | tagger: commit.author 41 | }); 42 | 43 | console.log(`Created tag ${release.tag}.`); 44 | 45 | await github.rest.git.createRef({ 46 | owner: context.repo.owner, 47 | repo: context.repo.repo, 48 | ref: `refs/tags/${release.tag}`, 49 | sha: context.sha 50 | }); 51 | 52 | // Compress the release asset(s) depending on whether the path is a folder and single file 53 | var assetArchivePath; 54 | var assetContentType; 55 | if (fs.lstatSync(release.asset).isDirectory()) { 56 | // Use zip for folders 57 | const folderName = path.basename(release.asset); 58 | assetContentType = 'application/zip'; 59 | assetArchivePath = path.join(release.asset, '..', `${folderName}.zip`); 60 | 61 | const zipOutput = cp.execSync(`zip -r ../${folderName}.zip .`, { cwd: release.asset, encoding: 'utf8' }); 62 | process.stdout.write(zipOutput); 63 | 64 | console.log(`Created zip archive ${path.basename(assetArchivePath)}.`); 65 | } else { 66 | // Use gzip for single files 67 | const file = path.basename(release.asset); 68 | const directory = path.dirname(release.asset); 69 | assetContentType = 'application/gzip'; 70 | assetArchivePath = path.join(directory, `${file}.gz`); 71 | 72 | const gzipOutput = cp.execSync(`gzip -9 ${file}`, { cwd: directory, encoding: 'utf8' }); 73 | process.stdout.write(gzipOutput); 74 | 75 | console.log(`Created gzip archive ${path.basename(assetArchivePath)}.`); 76 | } 77 | 78 | // Create the release 79 | const newRelease = await github.rest.repos.createRelease({ 80 | owner: context.repo.owner, 81 | repo: context.repo.repo, 82 | tag_name: release.tag, 83 | name: `${release.name} ${release.version}`, 84 | prerelease: release.prerelease, 85 | draft: true 86 | }); 87 | 88 | console.log(`Created new release for version ${release.version}.`); 89 | 90 | await github.rest.repos.uploadReleaseAsset({ 91 | owner: context.repo.owner, 92 | repo: context.repo.repo, 93 | release_id: newRelease.data.id, 94 | name: path.basename(assetArchivePath), 95 | data: fs.readFileSync(assetArchivePath), 96 | headers: { 97 | 'content-type': assetContentType, 98 | 'content-length': fs.statSync(assetArchivePath).size, 99 | } 100 | }); 101 | 102 | console.log(`Uploaded release asset ${path.basename(assetArchivePath)}.`); 103 | } 104 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage.Test/Clients/ValidateAzureStorageAccountOptions.Test.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using Keda.Scaler.DurableTask.AzureStorage.Clients; 6 | using Keda.Scaler.DurableTask.AzureStorage.Metadata; 7 | using Microsoft.Extensions.Options; 8 | using NSubstitute; 9 | using Xunit; 10 | 11 | namespace Keda.Scaler.DurableTask.AzureStorage.Test.Clients; 12 | 13 | public class ValidateAzureStorageAccountOptionsTest 14 | { 15 | private readonly ScalerOptions _scalerOptions = new(); 16 | private readonly ConfigureAzureStorageAccountOptions _configure; 17 | private readonly ValidateAzureStorageAccountOptions _validate; 18 | 19 | public ValidateAzureStorageAccountOptionsTest() 20 | { 21 | IOptionsSnapshot snapshot = Substitute.For>(); 22 | _ = snapshot.Get(default).Returns(_scalerOptions); 23 | _configure = new(snapshot); 24 | _validate = new(snapshot); 25 | } 26 | 27 | [Fact] 28 | public void GivenNullOptionsSnapshot_WhenCreatingValidate_ThenThrowArgumentNullException() 29 | { 30 | _ = Assert.Throws(() => new ValidateAzureStorageAccountOptions(null!)); 31 | 32 | IOptionsSnapshot nullSnapshot = Substitute.For>(); 33 | _ = nullSnapshot.Get(default).Returns(default(ScalerOptions)); 34 | _ = Assert.Throws(() => new ValidateAzureStorageAccountOptions(nullSnapshot)); 35 | } 36 | 37 | [Theory] 38 | [InlineData("ExampleEnvVariable")] 39 | [InlineData(null)] 40 | public void GivenUnresolvedConnectionString_WhenValidatingOptions_ThenReturnFailure(string? variableName) 41 | { 42 | GivenInvalidCombination_WhenValidatingOptions_ThenReturnFailure( 43 | variableName ?? AzureStorageAccountOptions.DefaultConnectionEnvironmentVariable, 44 | o => o.ConnectionFromEnv = variableName); 45 | } 46 | 47 | [Fact] 48 | public void GivenValidConnectionString_WhenValidatingOptions_ThenReturnSuccess() 49 | => GivenValidCombination_WhenValidatingOptions_ThenReturnSuccess(o => o.Connection = "foo=bar"); 50 | 51 | [Fact] 52 | public void GivenValidAccountName_WhenValidatingOptions_ThenReturnSuccess() 53 | => GivenValidCombination_WhenValidatingOptions_ThenReturnSuccess(o => o.AccountName = "unittest"); 54 | 55 | private void GivenInvalidCombination_WhenValidatingOptions_ThenReturnFailure(string failureSnippet, Action configure) 56 | { 57 | ArgumentNullException.ThrowIfNull(configure); 58 | 59 | AzureStorageAccountOptions options = new(); 60 | configure(_scalerOptions); 61 | _configure.Configure(options); 62 | 63 | ValidateOptionsResult result = _validate.Validate(Options.DefaultName, options); 64 | 65 | Assert.False(result.Succeeded); 66 | Assert.True(result.Failed); 67 | 68 | string failureMessage = Assert.Single(result.Failures); 69 | Assert.Contains(failureSnippet, failureMessage, StringComparison.Ordinal); 70 | } 71 | 72 | private void GivenValidCombination_WhenValidatingOptions_ThenReturnSuccess(Action configure) 73 | { 74 | ArgumentNullException.ThrowIfNull(configure); 75 | 76 | AzureStorageAccountOptions options = new(); 77 | configure(_scalerOptions); 78 | _configure.Configure(options); 79 | 80 | ValidateOptionsResult result = _validate.Validate(Options.DefaultName, options); 81 | 82 | Assert.True(result.Succeeded); 83 | Assert.False(result.Failed); 84 | Assert.Null(result.Failures); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Keda.Scaler.DurableTask.AzureStorage/Certificates/ConfigureCustomTrustStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright © William Sugarman. 2 | // Licensed under the MIT License. 3 | 4 | using System; 5 | using System.IO; 6 | using System.Security.Cryptography.X509Certificates; 7 | using System.Threading; 8 | using Microsoft.AspNetCore.Authentication.Certificate; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.FileProviders; 11 | using Microsoft.Extensions.Logging; 12 | using Microsoft.Extensions.Options; 13 | using Microsoft.Extensions.Primitives; 14 | 15 | namespace Keda.Scaler.DurableTask.AzureStorage.Certificates; 16 | 17 | internal sealed class ConfigureCustomTrustStore : IConfigureNamedOptions, IDisposable, IOptionsChangeTokenSource 18 | { 19 | private readonly CaCertificateFileOptions _options; 20 | private readonly ReaderWriterLockSlim _certificateLock; 21 | private readonly ILogger _logger; 22 | private readonly PhysicalFileProvider _fileProvider; 23 | private readonly IDisposable _changeTokenRegistration; 24 | private X509Certificate2Collection _certificates; 25 | private ConfigurationReloadToken _reloadToken; 26 | 27 | public string? Name => CertificateAuthenticationDefaults.AuthenticationScheme; 28 | 29 | [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Certificate disposed in collection.")] 30 | public ConfigureCustomTrustStore(IOptions options, ReaderWriterLockSlim certificateLock, ILoggerFactory loggerFactory) 31 | { 32 | ArgumentNullException.ThrowIfNull(options?.Value?.CertificateAuthority, nameof(options)); 33 | ArgumentNullException.ThrowIfNull(certificateLock); 34 | ArgumentNullException.ThrowIfNull(loggerFactory); 35 | 36 | _options = options.Value.CertificateAuthority; 37 | _certificateLock = certificateLock; 38 | _logger = loggerFactory.CreateLogger(LogCategories.Security); 39 | _certificates = [LoadPemFile(_options.Path)]; 40 | _fileProvider = new PhysicalFileProvider(Path.GetDirectoryName(_options.Path)!); 41 | _reloadToken = new ConfigurationReloadToken(); 42 | _changeTokenRegistration = ChangeToken.OnChange( 43 | () => _fileProvider.Watch(Path.GetFileName(_options.Path)), 44 | () => 45 | { 46 | Thread.Sleep(_options.ReloadDelayMs); 47 | Reload(LoadPemFile(_options.Path)); 48 | }); 49 | } 50 | 51 | public void Configure(CertificateAuthenticationOptions options) 52 | => Configure(Options.DefaultName, options); 53 | 54 | public void Configure(string? name, CertificateAuthenticationOptions options) 55 | { 56 | options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust; 57 | options.CustomTrustStore = _certificates; 58 | } 59 | 60 | public void Dispose() 61 | { 62 | _certificates[0].Dispose(); 63 | _changeTokenRegistration.Dispose(); 64 | _fileProvider.Dispose(); 65 | GC.SuppressFinalize(this); 66 | } 67 | 68 | public IChangeToken GetChangeToken() 69 | => _reloadToken; 70 | 71 | private void Reload(X509Certificate2 certificate) 72 | { 73 | try 74 | { 75 | _certificateLock.EnterWriteLock(); 76 | 77 | _certificates[0].Dispose(); 78 | _certificates = [certificate]; 79 | ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _reloadToken, new ConfigurationReloadToken()); 80 | previousToken.OnReload(); 81 | 82 | _logger.ReloadedCustomCertificateAuthority(_options.Path, certificate.Thumbprint); 83 | } 84 | finally 85 | { 86 | _certificateLock.ExitWriteLock(); 87 | } 88 | } 89 | 90 | private static X509Certificate2 LoadPemFile(string path) 91 | => X509CertificateLoader.LoadCertificateFromFile(path); 92 | } 93 | -------------------------------------------------------------------------------- /.github/actions/parse-chart/scripts/ParseVersions.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | param 3 | ( 4 | [Parameter(Mandatory=$False)] 5 | [ValidateNotNullOrEmpty()] 6 | [string] 7 | $ChartPath = (Join-Path $PSScriptRoot '..' '..' '..' '..' 'charts' 'durabletask-azurestorage-scaler' 'Chart.yaml'), 8 | 9 | [Parameter(Mandatory=$False)] 10 | [string] 11 | $WorkflowRunId = $null 12 | ) 13 | 14 | # Turn off trace and stop on any error 15 | Set-PSDebug -Off 16 | $ErrorActionPreference = "Stop" 17 | 18 | # Read the Chart.yaml 19 | # Note: Explicitly set TLS 1.2 based on https://rnelson0.com/2018/05/17/powershell-in-a-post-tls1-1-world/ 20 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 21 | Install-Module powershell-yaml -Force -Repository PSGallery -Scope CurrentUser 22 | $chart = Get-Content -Path $chartPath -Raw | ConvertFrom-Yaml -Ordered 23 | 24 | # Find the fields in the YAML file 25 | $appVersion = $chart['appVersion'] 26 | if (-Not $appVersion) { 27 | throw [InvalidOperationException]::new("Could not find field 'appVersion' in chart '$ChartPath'.") 28 | } 29 | 30 | $version = $chart['version'] 31 | if (-Not $version) { 32 | throw [InvalidOperationException]::new("Could not find field 'version' in chart '$ChartPath'.") 33 | } 34 | 35 | # Ensure the versions are valid semantic versions 36 | $isValid = $appVersion -match '^(?\d+)\.(?\d+)\.(?\d+)(?-[a-zA-Z]+\.\d+)?$' 37 | if (-Not $isValid) { 38 | throw [FormatException]::new("'$appVersion' denotes an invalid semantic version.") 39 | } 40 | 41 | $appVersionMatches = $Matches 42 | 43 | $isValid = $version -match '^(?\d+)\.(?\d+)\.(?\d+)(?-[a-zA-Z]+\.\d+)?$' 44 | if (-Not $isValid) { 45 | throw [FormatException]::new("'$version' denotes an invalid semantic version.") 46 | } 47 | 48 | $versionMatches = $Matches 49 | 50 | # Also ensure that the container image tag is up-to-date 51 | $annotations = $chart['annotations'] 52 | if ($annotations -And $annotations['artifacthub.io/images']) { 53 | $images = ConvertFrom-Yaml -Ordered $annotations['artifacthub.io/images'] 54 | $image = $images | Where-Object {$_.name -eq 'durabletask-azurestorage-scaler'} | Select-Object @{Name='image';Expression={$_.image}} | Select-Object -ExpandProperty image -First 1 55 | 56 | if ($image -And -Not $image.EndsWith(':' + $appVersion)) { 57 | throw [InvalidOperationException]::new("Tag for image 'durabletask-azurestorage-scaler' does not match appVersion '$appVersion'.") 58 | } 59 | } 60 | 61 | # Each version number for .NET is restricted to 16-bit numbers, so we'll only preserve the run id for the tag 62 | # See here for details: https://learn.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource 63 | $assemblyFileVersion = "$($appVersionMatches.Major).$($appVersionMatches.Minor).$($appVersionMatches.Patch).0" 64 | $assemblyVersion = "$($appVersionMatches.Major).0.0.0" 65 | 66 | # Adjust the version for Pull Requests (by passing the workflow run id) to differentiate them from official builds 67 | if ($WorkflowRunId) { 68 | $chartPrerelease = $True 69 | $chartVersion = "$($versionMatches.Major).$($versionMatches.Minor).$($versionMatches.Patch)-pr.$WorkflowRunId" 70 | $imagePrerelease = $True 71 | $imageTag = "$($appVersionMatches.Major).$($appVersionMatches.Minor).$($appVersionMatches.Patch)-pr.$WorkflowRunId" 72 | } 73 | else { 74 | $chartPrerelease = -Not [string]::IsNullOrEmpty($versionMatches.Suffix) 75 | $chartVersion = "$($versionMatches.Major).$($versionMatches.Minor).$($versionMatches.Patch)$($versionMatches.Suffix)" 76 | $imagePrerelease = -Not [string]::IsNullOrEmpty($appVersionMatches.Suffix) 77 | $imageTag = "$($appVersionMatches.Major).$($appVersionMatches.Minor).$($appVersionMatches.Patch)$($appVersionMatches.Suffix)" 78 | } 79 | 80 | # Create output variables 81 | "assemblyFileVersion=$assemblyFileVersion" >> $env:GITHUB_OUTPUT 82 | "assemblyVersion=$assemblyVersion" >> $env:GITHUB_OUTPUT 83 | "chartPrerelease=$chartPrerelease" >> $env:GITHUB_OUTPUT 84 | "chartVersion=$chartVersion" >> $env:GITHUB_OUTPUT 85 | "imageTag=$imageTag" >> $env:GITHUB_OUTPUT 86 | "imagePrerelease=$imagePrerelease" >> $env:GITHUB_OUTPUT 87 | --------------------------------------------------------------------------------