├── 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 |
--------------------------------------------------------------------------------