diff --git a/Elsa.sln b/Elsa.sln index 183315e6f..e41681668 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -194,6 +194,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.JsonWorkflowPr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.WorkflowProviders.FluentStorage", "src\modules\Elsa.WorkflowProviders.FluentStorage\Elsa.WorkflowProviders.FluentStorage.csproj", "{044C3108-FE79-460A-9C31-A03C30228836}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps", "src\samples\aspnet\Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps\Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj", "{3212A999-4AC4-4911-9AA4-92AB906BCB5E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -492,6 +494,10 @@ Global {044C3108-FE79-460A-9C31-A03C30228836}.Debug|Any CPU.Build.0 = Debug|Any CPU {044C3108-FE79-460A-9C31-A03C30228836}.Release|Any CPU.ActiveCfg = Release|Any CPU {044C3108-FE79-460A-9C31-A03C30228836}.Release|Any CPU.Build.0 = Release|Any CPU + {3212A999-4AC4-4911-9AA4-92AB906BCB5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3212A999-4AC4-4911-9AA4-92AB906BCB5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3212A999-4AC4-4911-9AA4-92AB906BCB5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3212A999-4AC4-4911-9AA4-92AB906BCB5E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {155227F0-A33B-40AA-A4B4-06F813EB921B} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F} @@ -578,5 +584,6 @@ Global {4FA6955C-6860-493F-ABD4-CE327A33EEA3} = {873BFC3E-63C2-4495-A503-5EC05DCD84E4} {C5043453-5FB8-4796-9A80-C4C766F2CB62} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5} {044C3108-FE79-460A-9C31-A03C30228836} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {3212A999-4AC4-4911-9AA4-92AB906BCB5E} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5} EndGlobalSection EndGlobal diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ArmClientProviders.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ArmClientProviders.cs new file mode 100644 index 000000000..556bc863b --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ArmClientProviders.cs @@ -0,0 +1,14 @@ +using Azure.ResourceManager; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Provides an instance. +/// +public static class ArmClientProviders +{ + /// + /// A default that uses + /// + public static readonly DefaultAzureCredentialArmClientProvider DefaultAzureCredential = new(); +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ArmClientUtils.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ArmClientUtils.cs deleted file mode 100644 index 92a599dfe..000000000 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ArmClientUtils.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Azure; -using Azure.ResourceManager; -using Azure.ResourceManager.AppContainers; -using Azure.ResourceManager.Resources; -using Azure.ResourceManager.Resources.Models; -using Microsoft.Extensions.Logging; -using Proto; -using Proto.Cluster; - -namespace Elsa.ProtoActor.Cluster.AzureContainerApps; - -public static class ArmClientUtils -{ - private static readonly ILogger Logger = Log.CreateLogger(nameof(ArmClientUtils)); - - public static async Task GetClusterMembers(this ArmClient client, string resourceGroupName, string containerAppName) - { - var members = new List(); - - var containerApp = await (await client.GetResourceGroupByName(resourceGroupName)).Value.GetContainerAppAsync(containerAppName); - - if (containerApp is null || !containerApp.HasValue) - { - Logger.LogError("Container App: {ContainerApp} in resource group: {ResourceGroup} is not found", containerApp, resourceGroupName); - return members.ToArray(); - } - - var containerAppRevisions = GetActiveRevisionsWithTraffic(containerApp).ToList(); - if (!containerAppRevisions.Any()) - { - Logger.LogError("Container App: {ContainerApp} in resource group: {ResourceGroup} does not contain any active revisions with traffic", containerAppName, resourceGroupName); - return members.ToArray(); - } - - var replicasWithTraffic = containerAppRevisions.SelectMany(r => r.GetContainerAppReplicas()); - - var allTags = (await containerApp.Value.GetTagResource().GetAsync()).Value.Data.TagValues; - - foreach (var replica in replicasWithTraffic) - { - var replicaNameTag = allTags.FirstOrDefault(kvp => kvp.Value == replica.Data.Name); - if (replicaNameTag.Key == null) - { - Logger.LogWarning("Skipping Replica with name: {Name}, no Proto Tags found", replica.Data.Name); - continue; - } - - var replicaNameTagPrefix = replicaNameTag.Key.Replace(ResourceTagLabels.LabelReplicaNameWithoutPrefix, string.Empty); - var currentReplicaTags = allTags.Where(kvp => kvp.Key.StartsWith(replicaNameTagPrefix)).ToDictionary(x => x.Key, x => x.Value); - - var memberId = currentReplicaTags.FirstOrDefault(kvp => kvp.Key.ToString().Contains(ResourceTagLabels.LabelMemberIdWithoutPrefix)).Value; - - var kinds = currentReplicaTags - .Where(kvp => kvp.Key.StartsWith(ResourceTagLabels.LabelKind(memberId))) - .Select(kvp => kvp.Key[(ResourceTagLabels.LabelKind(memberId).Length + 1)..]) - .ToArray(); - - var member = new Member - { - Id = currentReplicaTags[ResourceTagLabels.LabelMemberId(memberId)], - Port = int.Parse(currentReplicaTags[ResourceTagLabels.LabelPort(memberId)]), - Host = currentReplicaTags[ResourceTagLabels.LabelHost(memberId)], - Kinds = { kinds } - }; - - members.Add(member); - } - - return members.ToArray(); - } - - public static async Task AddMemberTags(this ArmClient client, string resourceGroupName, string containerAppName, Dictionary newTags) - { - var resourceTag = new Tag(); - foreach (var tag in newTags) - { - resourceTag.TagValues.Add(tag); - } - - var resourceGroup = await client.GetResourceGroupByName(resourceGroupName); - var containerApp = await resourceGroup.Value.GetContainerAppAsync(containerAppName); - var tagResource = containerApp.Value.GetTagResource(); - - var existingTags = (await tagResource.GetAsync()).Value.Data.TagValues; - foreach (var tag in existingTags) - { - resourceTag.TagValues.Add(tag); - } - - await tagResource.CreateOrUpdateAsync(WaitUntil.Completed, new TagResourceData(resourceTag)); - } - - public static async Task ClearMemberTags(this ArmClient client, string resourceGroupName, string containerAppName, string memberId) - { - var resourceGroup = await client.GetResourceGroupByName(resourceGroupName); - var containerApp = await resourceGroup.Value.GetContainerAppAsync(containerAppName); - var tagResource = containerApp.Value.GetTagResource(); - - var resourceTag = new Tag(); - var existingTags = (await tagResource.GetAsync()).Value.Data.TagValues; - - foreach (var tag in existingTags) - { - if (!tag.Key.StartsWith(ResourceTagLabels.LabelPrefix(memberId))) - { - resourceTag.TagValues.Add(tag); - } - } - - await tagResource.CreateOrUpdateAsync(WaitUntil.Completed, new TagResourceData(resourceTag)); - } - - public static async Task> GetResourceGroupByName(this ArmClient client, string resourceGroupName) => - await (await client.GetDefaultSubscriptionAsync()).GetResourceGroups().GetAsync(resourceGroupName); - - private static IEnumerable GetActiveRevisionsWithTraffic(ContainerAppResource containerApp) => - containerApp.GetContainerAppRevisions().Where(r => r.HasData && (r.Data.IsActive ?? false) && r.Data.TrafficWeight > 0); -} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProvider.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProvider.cs index 46206bd44..6f987e511 100644 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProvider.cs +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProvider.cs @@ -1,59 +1,64 @@ -using Azure.ResourceManager; +using System; +using System.Linq; +using System.Threading.Tasks; +using Azure.ResourceManager; using Azure.ResourceManager.AppContainers; using JetBrains.Annotations; using Microsoft.Extensions.Logging; -using Proto; -using Proto.Cluster; +using Microsoft.Extensions.Options; +using Proto.Cluster.AzureContainerApps.Stores.ResourceTags; using Proto.Utils; -namespace Elsa.ProtoActor.Cluster.AzureContainerApps; +namespace Proto.Cluster.AzureContainerApps; +/// +/// A cluster provider that uses Azure Container Apps to host the cluster. +/// [PublicAPI] public class AzureContainerAppsProvider : IClusterProvider { - private readonly ArmClient _client; - private readonly string _resourceGroup; - private readonly string _containerAppName; + private readonly IArmClientProvider _armClientProvider; + private readonly IClusterMemberStore _clusterMemberStore; + private readonly IOptions _options; + private readonly ILogger _logger; + private readonly string? _containerAppName; private readonly string _revisionName; private readonly string _replicaName; private readonly string _advertisedHost; - + private string _memberId = null!; private string _address = null!; - private global::Proto.Cluster.Cluster _cluster = null!; + private Cluster _cluster = null!; private string _clusterName = null!; private string[] _kinds = null!; private int _port; - - private static readonly ILogger Logger = Log.CreateLogger(); - private static readonly TimeSpan PollIntervalInSeconds = TimeSpan.FromSeconds(5); + private ArmClient _client = null!; /// /// Use this constructor to create a new instance. /// - /// An existing instance that you need to bring yourself. - /// The resource group name containing your Azure Container App. - /// The name of the container app. If not specified, the CONTAINER_APP_NAME environment variable is used. - /// The revision of the container app. If not specified, the CONTAINER_APP_REVISION environment variable is used. - /// The replica name of the container app. If not specified, the HOSTNAME environment variable is used. - /// The host or IP address of the container app. If not specified, will take the smallest local IP address (e.g. 127.0.0.1). + /// An to create instances. + /// The store to use for storing member information. + /// The options for this provider. + /// The logger to use. public AzureContainerAppsProvider( - ArmClient client, - string resourceGroup, - string? containerAppName = default, - string? revision = default, - string? replicaName = default, - string? advertisedHost = default) + IArmClientProvider armClientProvider, + IClusterMemberStore clusterMemberStore, + IOptions options, + ILogger logger) { - _client = client; - _resourceGroup = resourceGroup; - _containerAppName = containerAppName ?? Environment.GetEnvironmentVariable("CONTAINER_APP_NAME") ?? throw new Exception("No app name provided"); - _revisionName = revision ?? Environment.GetEnvironmentVariable("CONTAINER_APP_REVISION") ?? throw new Exception("No app revision provided"); - _replicaName = replicaName ?? Environment.GetEnvironmentVariable("HOSTNAME") ?? throw new Exception("No replica name provided"); - _advertisedHost = !string.IsNullOrEmpty(advertisedHost) ? advertisedHost : ConfigUtils.FindSmallestIpAddress().ToString(); + _armClientProvider = armClientProvider; + _clusterMemberStore = clusterMemberStore; + _options = options; + _logger = logger; + _containerAppName = Environment.GetEnvironmentVariable("CONTAINER_APP_NAME") ?? throw new Exception("No app name provided"); + _revisionName = Environment.GetEnvironmentVariable("CONTAINER_APP_REVISION") ?? throw new Exception("No app revision provided"); + _replicaName = Environment.GetEnvironmentVariable("HOSTNAME") ?? throw new Exception("No replica name provided"); + _advertisedHost = ConfigUtils.FindSmallestIpAddress().ToString(); } - public async Task StartMemberAsync(global::Proto.Cluster.Cluster cluster) + /// + public async Task StartMemberAsync(Cluster cluster) { var clusterName = cluster.Config.ClusterName; var (host, port) = cluster.System.GetAddress(); @@ -64,12 +69,15 @@ public class AzureContainerAppsProvider : IClusterProvider _port = port; _kinds = kinds; _address = $"{host}:{port}"; + _client = await _armClientProvider.CreateClientAsync(); - await RegisterMemberAsync(); + //await CleanupStoreAsync(cluster); + await RegisterMemberAsync().ConfigureAwait(false); StartClusterMonitor(); } - public Task StartClientAsync(global::Proto.Cluster.Cluster cluster) + /// + public Task StartClientAsync(Cluster cluster) { var clusterName = cluster.Config.ClusterName; var (_, port) = cluster.System.GetAddress(); @@ -83,103 +91,95 @@ public class AzureContainerAppsProvider : IClusterProvider return Task.CompletedTask; } - public async Task ShutdownAsync(bool graceful) => await DeregisterMemberAsync(); + /// + public async Task ShutdownAsync(bool graceful) => await DeregisterMemberAsync().ConfigureAwait(false); + + private async Task CleanupStoreAsync(Cluster cluster) + { + await _clusterMemberStore.ClearAsync(cluster.Config.ClusterName); + } private async Task RegisterMemberAsync() { - await Retry.Try(RegisterMemberInner, retryCount: Retry.Forever, onError: OnError, onFailed: OnFailed); + await Retry.Try(RegisterMemberInternal, retryCount: Retry.Forever, onError: OnError, onFailed: OnFailed).ConfigureAwait(false); - static void OnError(int attempt, Exception exception) => Logger.LogWarning(exception, "Failed to register service"); - static void OnFailed(Exception exception) => Logger.LogError(exception, "Failed to register service"); + void OnError(int attempt, Exception exception) => _logger.LogWarning(exception, "Failed to register service"); + void OnFailed(Exception exception) => _logger.LogError(exception, "Failed to register service"); } - private async Task RegisterMemberInner() + private async Task RegisterMemberInternal() { - var resourceGroup = await _client.GetResourceGroupByName(_resourceGroup); - var containerApp = await resourceGroup.Value.GetContainerAppAsync(_containerAppName); - var revision = await containerApp.Value.GetContainerAppRevisionAsync(_revisionName); + var subscriptionId = _options.Value.SubscriptionId; + var resourceGroupName = _options.Value.ResourceGroupName; + var resourceGroup = await _client.GetResourceGroupByNameAsync(resourceGroupName, subscriptionId).ConfigureAwait(false); + var containerApp = await resourceGroup.GetContainerAppAsync(_containerAppName).ConfigureAwait(false); + var revision = await containerApp.Value.GetContainerAppRevisionAsync(_revisionName).ConfigureAwait(false); - if (revision.Value.Data.TrafficWeight.GetValueOrDefault(0) == 0) + if ((revision.Value.Data.TrafficWeight ?? 0) == 0) return; - Logger.LogInformation( + var member = new Member + { + Id = _memberId, + Host = _advertisedHost, + Port = _port, + }; + + _logger.LogInformation( "[Cluster][AzureContainerAppsProvider] Registering service {ReplicaName} on {IpAddress}", _replicaName, _address); - var tags = new Dictionary - { - [ResourceTagLabels.LabelCluster(_memberId)] = _clusterName, - [ResourceTagLabels.LabelHost(_memberId)] = _advertisedHost, - [ResourceTagLabels.LabelPort(_memberId)] = _port.ToString(), - [ResourceTagLabels.LabelMemberId(_memberId)] = _memberId, - [ResourceTagLabels.LabelReplicaName(_memberId)] = _replicaName - }; - - foreach (var kind in _kinds) - { - var labelKey = $"{ResourceTagLabels.LabelKind(_memberId)}-{kind}"; - tags.TryAdd(labelKey, "true"); - } - - try - { - await _client.AddMemberTags(_resourceGroup, _containerAppName, tags); - } - catch (Exception x) - { - Logger.LogError(x, "Failed to update metadata"); - } + member.Kinds.AddRange(_kinds); + await _clusterMemberStore.RegisterAsync(_clusterName, member).ConfigureAwait(false); } - private void StartClusterMonitor() => + private void StartClusterMonitor() + { + var pollInterval = _options.Value.PollInterval; + var storeName = _clusterMemberStore.GetType().Name; + _ = SafeTask.Run(async () => { while (!_cluster.System.Shutdown.IsCancellationRequested) { - Logger.LogInformation("Calling ACS API"); + _logger.LogInformation("Looking for members in {Store}", storeName); try { - var members = await _client.GetClusterMembers(_resourceGroup, _containerAppName); + var members = (await _clusterMemberStore.ListAsync().ConfigureAwait(false)).ToArray(); if (members.Any()) { - Logger.LogInformation("Got members {Members}", members.Length); + _logger.LogInformation("Got members {Members}", members.Length); _cluster.MemberList.UpdateClusterTopology(members); } else { - Logger.LogWarning("Failed to get members from Azure Container Apps"); + _logger.LogWarning("Failed to get members from {Store}", storeName); } } catch (Exception x) { - Logger.LogError(x, "Failed to get members from Azure Container Apps"); + _logger.LogError(x, "Failed to get members from {Store}", storeName); } - await Task.Delay(PollIntervalInSeconds); + await Task.Delay(pollInterval).ConfigureAwait(false); } } ); + } private async Task DeregisterMemberAsync() { - await Retry.Try(DeregisterMemberInner, onError: OnError, onFailed: OnFailed); - - static void OnError(int attempt, Exception exception) => - Logger.LogWarning(exception, "Failed to deregister service"); - - static void OnFailed(Exception exception) => Logger.LogError(exception, "Failed to deregister service"); + await Retry.Try(DeregisterMemberInner, onError: OnError, onFailed: OnFailed).ConfigureAwait(false); + void OnError(int attempt, Exception exception) => _logger.LogWarning(exception, "Failed to deregister service"); + void OnFailed(Exception exception) => _logger.LogError(exception, "Failed to deregister service"); } private async Task DeregisterMemberInner() { - Logger.LogInformation( - "[Cluster][AzureContainerAppsProvider] Unregistering member {ReplicaName} on {IpAddress}", - _replicaName, - _address); - - await _client.ClearMemberTags(_resourceGroup, _containerAppName, _memberId); + _logger.LogInformation("[Cluster][AzureContainerAppsProvider] Unregistering member {ReplicaName} on {IpAddress}", _replicaName, _address); + await _clusterMemberStore.UnregisterAsync(_memberId).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProviderOptions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProviderOptions.cs new file mode 100644 index 000000000..011fa86c3 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/AzureContainerAppsProviderOptions.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Options for +/// +[PublicAPI] +public class AzureContainerAppsProviderOptions +{ + /// + /// The subscription ID to use. If not set, the default subscription will be used. + /// + public string? SubscriptionId { get; set; } + + /// + /// The name of the resource group to use. + /// + public string ResourceGroupName { get; set; } = default!; + + /// + /// The name of the container app to use. + /// + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(5); +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ConfigUtils.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ConfigUtils.cs index 51c7086c7..4a14de541 100644 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ConfigUtils.cs +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ConfigUtils.cs @@ -1,13 +1,14 @@ +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using JetBrains.Annotations; -namespace Elsa.ProtoActor.Cluster.AzureContainerApps; +namespace Proto.Cluster.AzureContainerApps; public static class ConfigUtils { - [PublicAPI] public static IPAddress FindSmallestIpAddress(AddressFamily family = AddressFamily.InterNetwork) { var addressCandidates = NetworkInterface.GetAllNetworkInterfaces() @@ -29,7 +30,7 @@ public static class ConfigUtils return result; - static bool CompareIpAddresses(IPAddress lhs, IPAddress? rhs) + static bool CompareIpAddresses(IPAddress lhs, [CanBeNull] IPAddress rhs) { if (rhs == null) return true; diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/DefaultAzureCredentialArmClientProvider.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/DefaultAzureCredentialArmClientProvider.cs new file mode 100644 index 000000000..5cd1d07b9 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/DefaultAzureCredentialArmClientProvider.cs @@ -0,0 +1,19 @@ +using Azure.Identity; +using Azure.ResourceManager; +using JetBrains.Annotations; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Provides an instance using +/// +[PublicAPI] +public class DefaultAzureCredentialArmClientProvider : IArmClientProvider +{ + /// + public ValueTask CreateClientAsync(CancellationToken cancellationToken = default) + { + var client = new ArmClient(new DefaultAzureCredential()); + return new(client); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj index 1a45594da..4dcb7dd5e 100644 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj @@ -10,9 +10,11 @@ This is a temporary stand-in until Proto.Actor provides an updated version of Proto.Cluster.AzureContainerApps. elsa module runtime protoactor cluster azure container apps + Proto.Cluster.AzureContainerApps - + + diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/IArmClientProvider.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/IArmClientProvider.cs new file mode 100644 index 000000000..38c42194e --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/IArmClientProvider.cs @@ -0,0 +1,15 @@ +using Azure.ResourceManager; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Provides an instance. +/// +public interface IArmClientProvider +{ + /// + /// Creates an instance. + /// + /// An instance. + ValueTask CreateClientAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/IClusterMemberStore.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/IClusterMemberStore.cs new file mode 100644 index 000000000..560a01178 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/IClusterMemberStore.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Represents a repository of members in a cluster. +/// +public interface IClusterMemberStore +{ + /// + /// Returns a list of all members in the cluster. + /// + /// The cancellation token. + /// A list of all members in the cluster. + ValueTask> ListAsync(CancellationToken cancellationToken = default); + + /// + /// Registers a member in the cluster. + /// + /// The name of the cluster. + /// The member to register. + /// The cancellation token. + ValueTask RegisterAsync(string clusterName, Member member, CancellationToken cancellationToken = default); + + /// + /// Unregisters a member from the cluster. + /// + /// The ID of the member to unregister. + /// The cancellation token. + ValueTask UnregisterAsync(string memberId, CancellationToken cancellationToken = default); + + /// + /// Clears all members from the cluster. + /// + /// The name of the cluster. + /// The cancellation token. + ValueTask ClearAsync(string clusterName, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Readme.md b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Readme.md new file mode 100644 index 000000000..6ce119db6 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Readme.md @@ -0,0 +1,59 @@ +# Azure Container Apps cluster provider + +Use this cluster provider when you're hosting your application in an Azure Container Apps cluster. + +The provider stores cluster member information using Azure Resource Tags on the container application and uses these tags to discover other cluster members within the same Azure Container Application Managed Environment. + +## Installation + +To install the provider, add the following code to your program: + +```csharp +services.AddAzureContainerAppsProvider(ArmClientProviders.DefaultAzureCredential, options => +{ + options.ResourceGroupName = "{the resource group name containing your container application}"; + + // Optionally, you can specify the subscription ID. If not specified, the provider will use the default subscription. + options.SubscriptionId = "{your subscription id}"; +}); +``` + +## Appsettings.json + +Instead of hardcoding the options above, you should instead bind the options using your app's configuration. +For example, consider the following appsettings.json: + +```json +{ + "AzureContainerApps": { + "ResourceGroupName": "{the resource group name containing your container application}", + "SubscriptionId": "{your subscription id}" + } +} +``` + +You can then bind the options using the following code: + +```csharp +services.AddAzureContainerAppsProvider(ArmClientProviders.DefaultAzureCredential, options => configuration.Bind("AzureContainerApps", options)); +``` + +## Custom member store + +By default, the provider will store cluster member information using Azure Resource Tags on the container application. +If you want to use a custom storage mechanism, you can do so by implementing the `IClusterMemberStore` interface and registering it with the service collection. + +For example, consider the following implementation: + +```csharp +public class RedisClusterMemberStorage : IClusterMemberStore +{ + ... +} +``` + +You can then register the implementation with the service collection: + +```csharp +services.AddSingleton(); +``` \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ResourceTagLabels.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ResourceTagLabels.cs deleted file mode 100644 index 151e2d3dc..000000000 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ResourceTagLabels.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Elsa.ProtoActor.Cluster.AzureContainerApps; - -public static class ResourceTagLabels -{ - public static string LabelPrefix(string memberId) => $"proto.cluster-{memberId}|"; - public static string LabelHost(string memberId) => LabelPrefix(memberId) + "host"; - public static string LabelPort(string memberId) => LabelPrefix(memberId) + "port"; - public static string LabelKind(string memberId) => LabelPrefix(memberId) + "kind"; - public static string LabelCluster(string memberId) => LabelPrefix(memberId) + "cluster"; - public static string LabelMemberId(string memberId) => LabelPrefix(memberId) + LabelMemberIdWithoutPrefix; - public const string LabelMemberIdWithoutPrefix = "memberId"; - public static string LabelReplicaName(string memberId) => LabelPrefix(memberId) + LabelReplicaNameWithoutPrefix; - public const string LabelReplicaNameWithoutPrefix = "replicaName"; -} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ResourceTagsMemberStoreOptionsValidator.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ResourceTagsMemberStoreOptionsValidator.cs new file mode 100644 index 000000000..1abff034f --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ResourceTagsMemberStoreOptionsValidator.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; +using Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Validates the to ensure that the required options are provided. +/// +public class AzureContainerAppsProviderOptionsValidator : IPostConfigureOptions +{ + /// + public void PostConfigure(string name, AzureContainerAppsProviderOptions options) + { + if (string.IsNullOrEmpty(options.ResourceGroupName)) + throw new Exception("No resource group provided"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..7817a0a2c --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Azure.ResourceManager; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +namespace Proto.Cluster.AzureContainerApps; + +/// +/// Adds extension methods to for registering the Azure Container Apps provider +/// +[PublicAPI] +public static class ServiceCollectionExtensions +{ + /// + /// Adds the Azure Container Apps provider to the service collection. + /// + /// The service collection to add the provider to. + /// An to create instances. + /// An optional action to configure the provider options. + /// The service collection. + public static IServiceCollection AddAzureContainerAppsProvider(this IServiceCollection services, IArmClientProvider? armClientProvider = default, [AllowNull] Action configure = null) + { + var configureOptions = configure ?? (_ => { }); + services.Configure(configureOptions); + services.ConfigureOptions(); + services.AddSingleton(); + + if (armClientProvider != null) + services.AddSingleton(armClientProvider); + + // Register the default member store. + services.AddSingleton(sp => + { + var clientProvider = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var options = sp.GetRequiredService>().Value; + return new ResourceTagsClusterMemberStore(clientProvider, logger, options.ResourceGroupName, options.SubscriptionId); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ArmClientUtils.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ArmClientUtils.cs new file mode 100644 index 000000000..0977b93f9 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ArmClientUtils.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; + +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// Adds extension methods to the class. +/// +public static class ArmClientUtils +{ + /// + /// Returns the specified resource group + /// + /// The being extended. + /// The name of the resource group. + /// The subscription ID. If not set, the default subscription will be used. + /// The cancellation token. + /// The resource group. + public static async Task GetResourceGroupByNameAsync(this ArmClient client, string resourceGroupName, string? subscriptionId = default, CancellationToken cancellationToken = default) + { + var resourceIdentifier = $"/subscriptions/{subscriptionId}"; + var subscription = subscriptionId != null ? client.GetSubscriptionResource(ResourceIdentifier.Parse(resourceIdentifier)) : await client.GetDefaultSubscriptionAsync(cancellationToken); + var response = await subscription.GetResourceGroupAsync(resourceGroupName, cancellationToken).ConfigureAwait(false); + return response.Value; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagNames.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagNames.cs new file mode 100644 index 000000000..bfa965e35 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagNames.cs @@ -0,0 +1,40 @@ +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// Static helpers for creating Azure resource tag names. +/// +public static class ResourceTagNames +{ + /// + /// The prefix for the tag name. + /// + public const string NamePrefix = "proto.cluster:member:"; + + /// + /// The prefix for the tag name. + /// + public const string KindPrefix = "kind:"; + + /// + /// Gets the prefixed name for the given member ID. + /// + /// The member ID. + /// The prefixed name. + public static string Prefix(string memberId) => $"{NamePrefix}{memberId}"; + + /// + /// Gets the prefixed name for the given member ID and name. + /// + /// The member ID. + /// The name. + /// The prefixed name. + public static string Prefix(string memberId, string name) => $"{Prefix(memberId)}:{name}"; + + /// + /// Gets the name for the cluster tag for the given member ID. + /// + /// The member ID. + /// The kind. + /// The prefixed kind. + public static string PrefixKind(string memberId, string kind) => Prefix(memberId, $"{KindPrefix}{kind}"); +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsClusterMemberStore.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsClusterMemberStore.cs new file mode 100644 index 000000000..7b6d7ce42 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsClusterMemberStore.cs @@ -0,0 +1,213 @@ +using System.Text.Json; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.AppContainers; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// Stores members in the form of resource tags of the Azure Container Application resource. +/// +[PublicAPI] +public class ResourceTagsClusterMemberStore : IClusterMemberStore +{ + private readonly IArmClientProvider _armClientProvider; + private readonly ILogger _logger; + private readonly string _containerAppName; + private readonly string _resourceGroupName; + private readonly string? _subscriptionId; + + private ArmClient? _armClient; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The options for this store. + /// The logger to use. + public ResourceTagsClusterMemberStore( + IArmClientProvider armArmClientProvider, + IOptions options, + ILogger logger) : this(armArmClientProvider, logger, options.Value.ResourceGroupName, options.Value.SubscriptionId) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The logger to use. + /// The name of the resource group. + /// The subscription ID. + internal ResourceTagsClusterMemberStore( + IArmClientProvider armArmClientProvider, + ILogger logger, + string resourceGroupName, + string? subscriptionId = default) + { + _armClientProvider = armArmClientProvider; + _logger = logger; + _resourceGroupName = resourceGroupName; + _subscriptionId = subscriptionId; + _containerAppName = Environment.GetEnvironmentVariable("CONTAINER_APP_NAME") ?? throw new Exception("No app name provided"); + } + + /// + public async ValueTask> ListAsync(CancellationToken cancellationToken = default) + { + var members = new List(); + var resourceGroupName = _resourceGroupName; + var containerAppName = _containerAppName; + var containerApp = await GetContainerAppAsync(cancellationToken).ConfigureAwait(false); + + if (containerApp == null) + { + _logger.LogError("Resource: {ResourceName} in resource group: {ResourceGroup} is not found", containerAppName, resourceGroupName); + return members.ToArray(); + } + + // Get the app container managed environment in order to get the other container apps. + var environmentId = containerApp.Data.EnvironmentId; + var containerApps = (await GetContainerAppsAsync(environmentId, cancellationToken).ConfigureAwait(false)).ToList(); + + // Build a list of all tags from all container apps. + var allTags = containerApps.SelectMany(x => x.Data.Tags).ToList(); + + var taggedMemberTags = allTags + .Where(kvp => kvp.Key.StartsWith(ResourceTagNames.NamePrefix) && !kvp.Key.Contains(ResourceTagNames.KindPrefix)) + .Select(x => x); + + foreach (var serializedTaggedMember in taggedMemberTags) + { + var taggedMember = Deserialize(serializedTaggedMember.Value); + var memberId = serializedTaggedMember.Key[ResourceTagNames.NamePrefix.Length..]; + var member = new Member + { + Id = memberId, + Host = taggedMember.Host, + Port = taggedMember.Port, + }; + + var kinds = allTags + .Where(x => x.Key.StartsWith(ResourceTagNames.Prefix(member.Id, ResourceTagNames.KindPrefix))) + .Select(x => x.Value); + + member.Kinds.AddRange(kinds); + members.Add(member); + } + + return members.ToArray(); + } + + /// + public async ValueTask RegisterAsync(string clusterName, Member member, CancellationToken cancellationToken = default) + { + var taggedMember = new TaggedMember(member.Host, member.Port, clusterName); + var serializedTaggedMember = Serialize(taggedMember); + + var tags = new Dictionary + { + [ResourceTagNames.Prefix(member.Id)] = serializedTaggedMember + }; + + foreach (var kind in member.Kinds) + tags[ResourceTagNames.PrefixKind(member.Id, kind)] = kind; + + try + { + await AddMemberTags(tags, cancellationToken).ConfigureAwait(false); + } + catch (Exception x) + { + _logger.LogError(x, "Failed to update metadata"); + } + } + + /// + public async ValueTask UnregisterAsync(string memberId, CancellationToken cancellationToken = default) + { + var containerApp = await GetContainerAppAsync(cancellationToken).ConfigureAwait(false); + + if(containerApp == null) + return; + + var existingTags = containerApp.Data.Tags; + var prefixedName = ResourceTagNames.Prefix(memberId); + + foreach (var tag in existingTags) + if (tag.Key.StartsWith(prefixedName)) + existingTags.Remove(tag.Key); + + await containerApp.SetTagsAsync(existingTags, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask ClearAsync(string clusterName, CancellationToken cancellationToken = default) + { + var containerApp = await GetContainerAppAsync(cancellationToken).ConfigureAwait(false); + + if(containerApp == null) + return; + + var existingTags = containerApp.Data.Tags; + var prefixedName = ResourceTagNames.NamePrefix; + + foreach (var tag in existingTags) + if (tag.Key.StartsWith(prefixedName)) + existingTags.Remove(tag.Key); + + await containerApp.SetTagsAsync(existingTags, cancellationToken).ConfigureAwait(false); + } + + private async Task AddMemberTags(Dictionary newTags, CancellationToken cancellationToken) + { + var containerApp = await GetContainerAppAsync(cancellationToken).ConfigureAwait(false); + + if(containerApp == null) + return; + + var tags = containerApp.Data.Tags; + + foreach (var tag in newTags) + tags[tag.Key] = tag.Value; + + await containerApp.SetTagsAsync(tags, cancellationToken); + } + + private async Task GetContainerAppAsync(CancellationToken cancellationToken) + { + var armClient = await GetArmClientAsync().ConfigureAwait(false); + var subscriptionId = _subscriptionId; + var resourceGroupName = _resourceGroupName; + var resourceGroup = await armClient.GetResourceGroupByNameAsync(resourceGroupName, subscriptionId, cancellationToken).ConfigureAwait(false); + var resource = await resourceGroup.GetContainerAppAsync(_containerAppName, cancellationToken).ConfigureAwait(false); + return resource.HasValue ? resource.Value : default; + } + + private async Task GetContainerAppManagedEnvironmentResourceAsync(ContainerAppResource containerApp, CancellationToken cancellationToken) + { + var armClient = await GetArmClientAsync().ConfigureAwait(false); + var environmentId = containerApp.Data.EnvironmentId; + var response = armClient.GetContainerAppManagedEnvironmentResource(environmentId); + return response; + } + + private async Task> GetContainerAppsAsync(ResourceIdentifier environmentId, CancellationToken cancellationToken) + { + var armClient = await GetArmClientAsync(); + var resourceGroupName = _resourceGroupName; + var subscriptionId = _subscriptionId; + var resourceGroup = await armClient.GetResourceGroupByNameAsync(resourceGroupName, subscriptionId, cancellationToken).ConfigureAwait(false); + return resourceGroup.GetContainerApps().Where(x => x.Data.EnvironmentId == environmentId); + } + + private static IEnumerable GetActiveRevisionsWithTraffic(ContainerAppResource containerApp) => + containerApp.GetContainerAppRevisions().Where(r => r.HasData && (r.Data.IsActive ?? false) && r.Data.TrafficWeight > 0); + + private static string Serialize(TaggedMember taggedMember) => JsonSerializer.Serialize(taggedMember); + private static TaggedMember Deserialize(string json) => JsonSerializer.Deserialize(json)!; + private async Task GetArmClientAsync() => _armClient ??= await _armClientProvider.CreateClientAsync().ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsMemberStoreOptions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsMemberStoreOptions.cs new file mode 100644 index 000000000..16eddc06b --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsMemberStoreOptions.cs @@ -0,0 +1,17 @@ +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// Options for the . +/// +public class ResourceTagsMemberStoreOptions +{ + /// + /// The subscription ID to use. If not set, the default subscription will be used. + /// + public string? SubscriptionId { get; set; } + + /// + /// The name of the resource group to use. + /// + public string ResourceGroupName { get; set; } = default!; +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsMemberStoreOptionsValidator.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsMemberStoreOptionsValidator.cs new file mode 100644 index 000000000..ff786867e --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ResourceTagsMemberStoreOptionsValidator.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.Extensions.Options; + +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// Validates the to ensure that the required options are provided. +/// +public class ResourceTagsMemberStoreOptionsValidator : IPostConfigureOptions +{ + /// + public void PostConfigure(string name, ResourceTagsMemberStoreOptions options) + { + if (string.IsNullOrEmpty(options.ResourceGroupName)) + throw new Exception("No resource group provided"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..1be11355e --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// Adds extension methods to for registering the Azure Container Apps provider +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the to the service collection. + /// + /// The service collection to add the provider to. + /// An optional action to configure the provider options. + /// The service collection. + public static IServiceCollection AddResourceTagsMemberStore(this IServiceCollection services, [AllowNull]Action configure = null) + { + var configureOptions = configure ?? (_ => { }); + services.Configure(configureOptions); + services.ConfigureOptions(); + services.Replace(new ServiceDescriptor(typeof(IClusterMemberStore), typeof(ResourceTagsClusterMemberStore), ServiceLifetime.Singleton)); + + return services; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/TaggedMember.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/TaggedMember.cs new file mode 100644 index 000000000..f78e7f97e --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/TaggedMember.cs @@ -0,0 +1,6 @@ +namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; + +/// +/// A member with a cluster name. +/// +public record TaggedMember(string Host, int Port, string Cluster); \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Dockerfile b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Dockerfile new file mode 100644 index 000000000..fef17e8f8 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /src +COPY ["src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj", "src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/"] +RUN dotnet restore "src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj" +COPY . . +WORKDIR "/src/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps" +RUN dotnet build "Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.dll"] diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj new file mode 100644 index 000000000..9feace8c2 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps.csproj @@ -0,0 +1,34 @@ + + + + net7.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs new file mode 100644 index 000000000..a12d208f2 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs @@ -0,0 +1,108 @@ +using Elsa.EntityFrameworkCore.Extensions; +using Elsa.EntityFrameworkCore.Modules.Labels; +using Elsa.EntityFrameworkCore.Modules.Management; +using Elsa.EntityFrameworkCore.Modules.Runtime; +using Elsa.Extensions; +using Elsa.ProtoActor.Protos; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Data.Sqlite; +using Proto.Cluster.AzureContainerApps; +using Proto.Persistence.Sqlite; +using Proto.Remote; +using Proto.Remote.GrpcNet; + +var builder = WebApplication.CreateBuilder(args); +var services = builder.Services; +var configuration = builder.Configuration; +var sqliteConnectionString = configuration.GetConnectionString("Sqlite")!; +var identitySection = configuration.GetSection("Identity"); +var identityTokenSection = identitySection.GetSection("Tokens"); +var protoActorSection = configuration.GetSection("ProtoActor"); +var protoActorClusterSection = protoActorSection.GetSection("Cluster"); + +// Configure Proto Actor cluster provider services. +services.AddAzureContainerAppsProvider(ArmClientProviders.DefaultAzureCredential, options => protoActorClusterSection.GetSection("AzureContainerApps").Bind(options)); + +// Add Elsa services. +services + .AddElsa(elsa => elsa + .AddActivitiesFrom() + .UseIdentity(identity => + { + identity.IdentityOptions = options => identitySection.Bind(options); + identity.TokenOptions = options => identityTokenSection.Bind(options); + identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options)); + identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options)); + identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options)); + }) + .UseDefaultAuthentication() + .UseWorkflowManagement(management => + { + // Use EF core for workflow definitions and instances. + management.UseEntityFrameworkCore(m => m.UseSqlite(sqliteConnectionString)); + }) + .UseWorkflowRuntime(runtime => + { + // Use EF core for triggers and bookmarks. + runtime.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString)); + + // Use EF core for execution log records. + runtime.UseExecutionLogRecords(log => log.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))); + + // Install a workflow state exporter to capture workflow states and store them in IWorkflowInstanceStore. + runtime.UseAsyncWorkflowStateExporter(); + + // Use Proto.Actor for workflow execution. + runtime.UseProtoActor(protoActor => + { + var advertisedHost = ConfigUtils.FindSmallestIpAddress().ToString(); + + protoActor.ClusterProvider = sp => sp.GetRequiredService(); + + protoActor.RemoteConfig = _ => GrpcNetRemoteConfig + .BindTo(advertisedHost) + .WithProtoMessages(EmptyReflection.Descriptor) + .WithProtoMessages(MessagesReflection.Descriptor) + .WithLogLevelForDeserializationErrors(LogLevel.Critical) + .WithRemoteDiagnostics(true); // required by proto.actor dashboard + + protoActor.PersistenceProvider = _ => new SqliteProvider(new SqliteConnectionStringBuilder(sqliteConnectionString)); + }); + }) + .UseLabels(labels => labels.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) + .UseScheduling() + .UseWorkflowsApi(api => api.AddFastEndpointsAssembly()) + .UseJavaScript() + .UseLiquid() + .UseHttp() + ); + +services.AddHealthChecks(); +services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin())); + +// Configure middleware pipeline. +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); + +// CORS. +app.UseCors(); + +// Health checks. +app.MapHealthChecks("/"); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Elsa API endpoints for designer. +app.UseWorkflowsApi(); + +// Captures unhandled exceptions and returns a JSON response. +app.UseJsonSerializationErrorHandler(); + +// Elsa HTTP Endpoint activities +app.UseWorkflows(); + +// Run. +app.Run(); \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Properties/launchSettings.json b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Properties/launchSettings.json new file mode 100644 index 000000000..9c4ce6f25 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:23224", + "sslPort": 44356 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5244", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7187;http://localhost:5244", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/README.md b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/README.md new file mode 100644 index 000000000..3748fa95f --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/README.md @@ -0,0 +1,10 @@ +# Server + +This project represents an Elsa application that hosts workflows and exposes API endpoints to manage & execute workflows using the Proto Actor runtime and Azure Container Apps. + +## Secrets +The following are the secrets stored in hashed form in appsettings.json: + +**API key**: `4E753976726458745954355043687772-e54d5a2c-33a3-4c05-a216-b09569062aed` +**Admin user**: `admin` +**Admin password**: `password` \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.Development.json b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.Development.json new file mode 100644 index 000000000..e24890fc1 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ProtoActor": { + "Cluster": { + "AzureContainerApps": { + "SubscriptionId": "8e23037a-420f-4ad0-9594-9d194de29e84", + "ResourceGroupName": "elsa-workflows-test", + "PollInterval": "00:00:05" + } + } + } +} diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json new file mode 100644 index 000000000..4195e124b --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json @@ -0,0 +1,54 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Sqlite": "Data Source=elsa.sqlite.db;Cache=Shared;" + }, + "Identity": { + "Tokens": { + "SigningKey": "secret-signing-key", + "AccessTokenLifetime": "1:00:00:00", + "RefreshTokenLifetime": "1:00:10:00" + }, + "Roles": [{ + "Id": "admin", + "Name": "Administrator", + "Permissions": ["*"] + }], + "Users": [ + { + "Id": "a2323f46-42db-4e15-af8b-94238717d817", + "Name": "admin", + "HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=", + "HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds=", + "Roles": ["admin"] + } + ], + "Applications": [{ + "id": "529572c2df854b13807b8bf23f1784cd", + "name": "Postman", + "roles": [ + "admin" + ], + "clientId": "Nu9vrdXtYT5PChwr", + "clientSecret": "011pp2C$|j01-qrMZpC9VC0F00XCJq(5", + "hashedApiKey": "d0rDld3A+ugKmdctGtMzOLTYjQFkOlUWN+kt0VyW9D0=", + "hashedApiKeySalt": "EnutGOyy5MuJWV0fF5jCQiciK7a8PU/DRF+fr6nekSY=", + "hashedClientSecret": "ERia2zBcCSWb/9dvB0grQ9yf7fWgFrClNeR8A5RMTzk=", + "hashedClientSecretSalt": "z3z8KmzHt+xkAj/zYTXcB8I7y0xAkLm95v4Er/oNqiY=" + }] + }, + "ProtoActor": { + "ClusterProvider": { + "AzureContainerApps": { + "ResourceGroupName": "ElsaWorkflows", + "PollInterval": "00:00:05" + } + } + } +}