Add Platform integration

This commit is contained in:
Sipke Schoorstra 2026-06-18 18:27:27 +02:00
parent 20c1064ca5
commit e9d89969b7
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
29 changed files with 1569 additions and 0 deletions

View file

@ -131,6 +131,9 @@
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4"/>
<PackageVersion Include="Jint" Version="4.4.2"/>
<PackageVersion Include="LinqKit.Core" Version="1.2.11"/>
<PackageVersion Include="Loom" Version="0.0.1-preview.10"/>
<PackageVersion Include="Loom.Abstractions" Version="0.0.1-preview.10"/>
<PackageVersion Include="Loom.Serialization.Json" Version="0.0.1-preview.10"/>
<PackageVersion Include="MailKit" Version="4.14.1"/>
<PackageVersion Include="MassTransit" Version="8.5.7"/>
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.5.7"/>

View file

@ -437,6 +437,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.AI.Persistence.EFCore.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.AI.Persistence.EFCore.Oracle", "src\modules\Elsa.AI.Persistence.EFCore.Oracle\Elsa.AI.Persistence.EFCore.Oracle.csproj", "{743C1835-E41E-4E5C-9D12-585BAEE59EEE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Platform.Integration", "src\modules\Elsa.Platform.Integration\Elsa.Platform.Integration.csproj", "{11F7C89F-835A-4D78-A846-9FCB463DA80C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Platform.Integration.UnitTests", "test\unit\Elsa.Platform.Integration.UnitTests\Elsa.Platform.Integration.UnitTests.csproj", "{84E70F39-2C24-4C93-A30F-6EC1330E2F49}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "platform", "platform", "{B284D6B0-E488-4062-ABC8-7AEB25784931}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -1957,6 +1963,30 @@ Global
{743C1835-E41E-4E5C-9D12-585BAEE59EEE}.Release|x64.Build.0 = Release|Any CPU
{743C1835-E41E-4E5C-9D12-585BAEE59EEE}.Release|x86.ActiveCfg = Release|Any CPU
{743C1835-E41E-4E5C-9D12-585BAEE59EEE}.Release|x86.Build.0 = Release|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Debug|x64.ActiveCfg = Debug|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Debug|x64.Build.0 = Debug|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Debug|x86.ActiveCfg = Debug|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Debug|x86.Build.0 = Debug|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Release|Any CPU.Build.0 = Release|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Release|x64.ActiveCfg = Release|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Release|x64.Build.0 = Release|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Release|x86.ActiveCfg = Release|Any CPU
{11F7C89F-835A-4D78-A846-9FCB463DA80C}.Release|x86.Build.0 = Release|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Debug|Any CPU.Build.0 = Debug|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Debug|x64.ActiveCfg = Debug|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Debug|x64.Build.0 = Debug|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Debug|x86.ActiveCfg = Debug|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Debug|x86.Build.0 = Debug|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|Any CPU.Build.0 = Release|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|x64.ActiveCfg = Release|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|x64.Build.0 = Release|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|x86.ActiveCfg = Release|Any CPU
{84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -2124,6 +2154,9 @@ Global
{32F2EE33-8190-41D7-BD12-45AA02BEB930} = {F52DF980-C8F7-49D5-A8A1-249B44223600}
{2D51FFE0-F114-48E1-BF58-FCED0FD1944F} = {F52DF980-C8F7-49D5-A8A1-249B44223600}
{743C1835-E41E-4E5C-9D12-585BAEE59EEE} = {F52DF980-C8F7-49D5-A8A1-249B44223600}
{84E70F39-2C24-4C93-A30F-6EC1330E2F49} = {18453B51-25EB-4317-A4B3-B10518252E92}
{B284D6B0-E488-4062-ABC8-7AEB25784931} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{11F7C89F-835A-4D78-A846-9FCB463DA80C} = {B284D6B0-E488-4062-ABC8-7AEB25784931}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -7,6 +7,7 @@
<add key="cshells-feedz" value="https://f.feedz.io/sfmskywalker/cshells/nuget/index.json" />
<add key="elsa-preview-feedz" value="https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json" />
<add key="valence-consolelogstream-feedz" value="https://f.feedz.io/valence-works/consolelogstream/nuget/index.json" />
<add key="valence-loom-feedz" value="https://f.feedz.io/valence-works/loom/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="NuGet official package source">
@ -24,5 +25,9 @@
<package pattern="ConsoleLogStreaming" />
<package pattern="ConsoleLogStreaming.*" />
</packageSource>
<packageSource key="valence-loom-feedz">
<package pattern="Loom" />
<package pattern="Loom.*" />
</packageSource>
</packageSourceMapping>
</configuration>

View file

@ -25,6 +25,7 @@
<ProjectReference Include="..\..\modules\Elsa.Http\Elsa.Http.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Identity\Elsa.Identity.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Platform.Integration\Elsa.Platform.Integration.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Resilience\Elsa.Resilience.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Secrets.JavaScript\Elsa.Secrets.JavaScript.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Secrets\Elsa.Secrets.csproj" />

View file

@ -7,6 +7,7 @@ using Elsa.Diagnostics.ConsoleLogs.Dashboard.ShellFeatures;
using Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures;
using Elsa.ModularServer.Web;
using Elsa.ModularServer.Web.Catalog;
using Elsa.Platform.Integration.ShellFeatures;
using Elsa.ShellFeatures;
using Elsa.Workflows.Api.ShellFeatures;
using Elsa.Workflows.Management.ShellFeatures;
@ -29,6 +30,7 @@ ConsoleStreamHook.Install();
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;
configuration.AddJsonFile(configuration["Elsa:PlatformIntegration:ShellOverlayPath"] ?? "platform-shell-overrides.json", optional: true, reloadOnChange: false);
var serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString();
builder.Logging.AddOpenTelemetry(logging =>
@ -77,6 +79,7 @@ builder.AddShells(shells => shells
typeof(WorkflowRuntimeFeature),
typeof(WorkflowsFeature),
typeof(DistributedRuntimeFeature),
typeof(ElsaPlatformIntegrationFeature),
typeof(DashboardApiFeature),
typeof(WorkflowRuntimeDashboardFeature),
typeof(ConsoleLogsDashboardFeature),

View file

@ -6,6 +6,18 @@
}
},
"AllowedHosts": "*",
"Elsa": {
"PlatformIntegration": {
"Enabled": false,
"PlatformEndpoint": "https://localhost:7001",
"WorkspaceId": "00000000-0000-0000-0000-000000000000",
"EngineId": "00000000-0000-0000-0000-000000000000",
"EngineSecret": "",
"PollInterval": "00:00:05",
"ClaimLeaseDuration": "00:05:00",
"ShellOverlayPath": "platform-shell-overrides.json"
}
},
"CShells": {
"Shells": [
{

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Integrates Elsa workflow runtimes with Elsa Platform deployment commands.
</Description>
<PackageTags>elsa module platform integration deployment workflows</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Loom" />
<PackageReference Include="Loom.Abstractions" />
<PackageReference Include="Loom.Serialization.Json" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -0,0 +1,15 @@
namespace Elsa.Platform.Integration.Models;
public sealed class PlatformRecipeArtifact(IReadOnlyDictionary<string, string> textEntries)
{
private readonly IReadOnlyDictionary<string, string> _textEntries = textEntries;
public bool TryGetText(string path, out string content)
{
var normalizedPath = NormalizePath(path);
return _textEntries.TryGetValue(normalizedPath, out content!);
}
private static string NormalizePath(string path) =>
path.Replace('\\', '/').TrimStart('/');
}

View file

@ -0,0 +1,138 @@
using System.Text.Json.Serialization;
namespace Elsa.Platform.Integration.Models;
[JsonConverter(typeof(JsonStringEnumConverter<PlatformRuntimeCommandAction>))]
public enum PlatformRuntimeCommandAction
{
Unknown,
Deploy,
Rollback,
Validate,
RuntimeControl
}
[JsonConverter(typeof(JsonStringEnumConverter<PlatformRuntimeCommandStatus>))]
public enum PlatformRuntimeCommandStatus
{
Unknown,
Pending,
Claimed,
Running,
Completed,
Failed,
Rejected,
Cancelled,
RecoveryRequired,
Expired
}
[JsonConverter(typeof(JsonStringEnumConverter<PlatformArtifactStatus>))]
public enum PlatformArtifactStatus
{
Pending,
Downloading,
Validated,
Applying,
Applied,
Failed,
Rejected,
Skipped
}
[JsonConverter(typeof(JsonStringEnumConverter<PlatformDiagnosticSeverity>))]
public enum PlatformDiagnosticSeverity
{
Info,
Warning,
Error
}
public sealed record PlatformRuntimeCommandListResponse(IReadOnlyList<PlatformRuntimeCommand> Commands);
public sealed record PlatformRuntimeCommandClaimRequest(Guid EngineId, string WorkerId, int LeaseSeconds);
public sealed record PlatformRuntimeCommandClaimResponse(PlatformRuntimeCommand Command, string LeaseToken);
public sealed record PlatformRuntimeCommandHeartbeatRequest(string LeaseToken, string WorkerId);
public sealed record PlatformRuntimeCommandProgressRequest(string LeaseToken, string Status, int? PercentComplete, string Message);
public sealed record PlatformRuntimeCommandCompleteRequest(
string LeaseToken,
PlatformArtifactDigest? ObservedArtifactDigest,
string? RuntimeReference,
IReadOnlyList<PlatformDiagnostic> Diagnostics,
IReadOnlyList<PlatformArtifactOutcome>? Artifacts = null);
public sealed record PlatformRuntimeCommandFailRequest(
string LeaseToken,
IReadOnlyList<PlatformDiagnostic> Diagnostics,
IReadOnlyList<PlatformArtifactOutcome>? Artifacts = null);
public sealed record PlatformRuntimeCommandRejectRequest(
string LeaseToken,
IReadOnlyList<PlatformDiagnostic> Diagnostics,
IReadOnlyList<PlatformArtifactOutcome>? Artifacts = null);
public sealed record PlatformRuntimeCommand(
Guid Id,
Guid WorkspaceId,
Guid RunId,
Guid EnvironmentId,
Guid EngineId,
PlatformRuntimeCommandAction Action,
PlatformRuntimeCommandStatus Status,
PlatformRuntimeCommandArtifactReference? Artifact,
PlatformRuntimeCommandRevisionReference? Revision,
string IdempotencyKey,
string? WorkerId,
DateTimeOffset? ClaimedAt,
DateTimeOffset? LeaseExpiresAt,
DateTimeOffset? HeartbeatAt,
int AttemptNumber,
int? PercentComplete,
string? ProgressMessage,
PlatformArtifactDigest? ObservedArtifactDigest,
string? RuntimeReference,
IReadOnlyList<PlatformDiagnostic> Diagnostics,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? AvailableAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset? CompletedAt,
IReadOnlyList<PlatformArtifactItem>? Artifacts = null);
public sealed record PlatformRuntimeCommandArtifactReference(
Guid? ArtifactRecordId,
string? ArtifactId,
string? ArtifactTypeId,
PlatformArtifactDigest? ContentDigest);
public sealed record PlatformRuntimeCommandRevisionReference(Guid? RevisionId);
public sealed record PlatformArtifactItem(
Guid ArtifactRecordId,
string ArtifactId,
string ArtifactTypeId,
string? ArtifactSchemaVersion,
PlatformArtifactDigest ContentDigest,
string DisplayName,
string? DownloadUrl,
PlatformArtifactStatus Status,
PlatformArtifactDigest? ObservedDigest,
string? RuntimeReference,
IReadOnlyList<PlatformDiagnostic>? Diagnostics);
public sealed record PlatformArtifactOutcome(
Guid ArtifactRecordId,
PlatformArtifactStatus Status,
PlatformArtifactDigest? ObservedDigest = null,
string? RuntimeReference = null,
IReadOnlyList<PlatformDiagnostic>? Diagnostics = null);
public sealed record PlatformArtifactDigest(string Algorithm, string Value);
public sealed record PlatformDiagnostic(string Code, PlatformDiagnosticSeverity Severity, string Message);
public sealed record PlatformProblemDetails(string? Title, string? Detail);

View file

@ -0,0 +1,53 @@
namespace Elsa.Platform.Integration.Options;
public class ElsaPlatformIntegrationOptions
{
public const string ConfigurationSection = "Elsa:PlatformIntegration";
public bool Enabled { get; set; }
public Uri? PlatformEndpoint { get; set; }
public Guid WorkspaceId { get; set; }
public Guid EngineId { get; set; }
public string? EngineSecret { get; set; }
public string WorkerId { get; set; } = $"{Environment.MachineName}:{Environment.ProcessId}";
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan ClaimLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public long MaxArtifactBytes { get; set; } = 4 * 1024 * 1024;
public string ShellOverlayPath { get; set; } = "platform-shell-overrides.json";
public IReadOnlyList<string> Capabilities { get; set; } = ["loom.recipe.apply"];
public void Validate()
{
if (!Enabled)
return;
if (PlatformEndpoint is null)
throw new InvalidOperationException("Elsa Platform endpoint is required when Platform integration is enabled.");
if (WorkspaceId == Guid.Empty)
throw new InvalidOperationException("Elsa Platform workspace ID is required when Platform integration is enabled.");
if (EngineId == Guid.Empty)
throw new InvalidOperationException("Elsa Platform engine ID is required when Platform integration is enabled.");
if (string.IsNullOrWhiteSpace(EngineSecret))
throw new InvalidOperationException("Elsa Platform engine secret is required when Platform integration is enabled.");
if (string.IsNullOrWhiteSpace(WorkerId))
throw new InvalidOperationException("Elsa Platform worker ID is required when Platform integration is enabled.");
if (PollInterval <= TimeSpan.Zero)
throw new InvalidOperationException("Elsa Platform poll interval must be positive.");
if (ClaimLeaseDuration < TimeSpan.FromSeconds(1) || ClaimLeaseDuration.TotalSeconds > int.MaxValue)
throw new InvalidOperationException("Elsa Platform claim lease duration must be between 1 second and the maximum supported Platform lease.");
if (MaxArtifactBytes <= 0 || MaxArtifactBytes > Array.MaxLength)
throw new InvalidOperationException("Elsa Platform maximum artifact size must be between 1 byte and the maximum runtime buffer size.");
if (string.IsNullOrWhiteSpace(ShellOverlayPath))
throw new InvalidOperationException("Elsa Platform shell overlay path is required when Platform integration is enabled.");
}
}

View file

@ -0,0 +1,209 @@
using System.IO.Compression;
using System.Security.Cryptography;
using CShells.Lifecycle;
using Elsa.Platform.Integration.Models;
using Elsa.Platform.Integration.Steps;
using Loom;
namespace Elsa.Platform.Integration.Services;
public class ElsaLoomRecipeArtifactApplier(
IServiceProvider serviceProvider,
IShellRegistry shellRegistry) : IPlatformRecipeArtifactApplier
{
private static readonly JsonRecipeSerializer Serializer = new();
public async Task<PlatformRecipeArtifactApplyResult> ApplyAsync(
PlatformRuntimeCommand command,
PlatformArtifactItem artifact,
Stream artifactZip,
CancellationToken cancellationToken = default)
{
var observedDigest = await ComputeDigestAsync(artifactZip, cancellationToken);
if (!DigestEquals(observedDigest, artifact.ContentDigest))
{
return Rejected(
observedDigest,
"elsa-platform.artifact-digest-mismatch",
"Downloaded recipe artifact digest did not match the Platform command digest.");
}
var textEntries = await ReadTextEntriesAsync(artifactZip, cancellationToken);
var recipeJson = FindRecipeJson(textEntries);
if (recipeJson is null)
return Rejected(observedDigest, "elsa-platform.recipe-payload-missing", "Loom recipe artifact ZIP did not contain a recipe JSON payload.");
Recipe recipe;
try
{
recipe = Serializer.Deserialize(recipeJson);
}
catch (RecipeSerializationException ex)
{
return Rejected(observedDigest, "elsa-platform.recipe-payload-invalid", ex.Message);
}
var reloadTracker = new PlatformShellReloadTracker();
var recipeServices = new PlatformRecipeServiceProvider(
serviceProvider,
new Dictionary<Type, object>
{
[typeof(PlatformRecipeArtifact)] = new PlatformRecipeArtifact(textEntries),
[typeof(PlatformShellReloadTracker)] = reloadTracker
});
var engine = RecipeEngine.Create()
.RegisterStep<VerifyCapabilitiesStep>()
.RegisterStep<ImportWorkflowDefinitionStep>()
.RegisterStep<ConfigureFeaturesStep>()
.RegisterStep<ConfigureSettingsStep>();
var runResult = await engine.RunAsync(recipe, new RecipeRunOptions
{
Services = recipeServices
}, cancellationToken);
if (!runResult.Succeeded)
{
var status = runResult.Status == RecipeRunStatus.ValidationFailed
? PlatformArtifactStatus.Rejected
: PlatformArtifactStatus.Failed;
return new PlatformRecipeArtifactApplyResult(
status,
observedDigest,
RuntimeReference(recipe),
ToPlatformDiagnostics(runResult.Diagnostics, runResult.Error));
}
var reloadFailure = await ReloadShellsAsync(reloadTracker, cancellationToken);
if (reloadFailure is not null)
{
return new PlatformRecipeArtifactApplyResult(
PlatformArtifactStatus.Failed,
observedDigest,
RuntimeReference(recipe),
[reloadFailure]);
}
var diagnostics = ToPlatformDiagnostics(runResult.Diagnostics, null);
if (diagnostics.Count == 0)
diagnostics = [PlatformDiagnosticSanitizer.Info("elsa-platform.recipe-applied", "Loom recipe artifact was applied.")];
return new PlatformRecipeArtifactApplyResult(
PlatformArtifactStatus.Applied,
observedDigest,
RuntimeReference(recipe),
diagnostics);
}
private async Task<PlatformDiagnostic?> ReloadShellsAsync(
PlatformShellReloadTracker reloadTracker,
CancellationToken cancellationToken)
{
foreach (var shellId in reloadTracker.ShellIds)
{
var result = await shellRegistry.ReloadAsync(shellId, cancellationToken);
if (result.Error is not null)
{
return PlatformDiagnosticSanitizer.Error(
"elsa-platform.shell-reload-failed",
$"Shell '{shellId}' reload failed: {result.Error.Message}");
}
}
return null;
}
private static async Task<IReadOnlyDictionary<string, string>> ReadTextEntriesAsync(
Stream artifactZip,
CancellationToken cancellationToken)
{
artifactZip.Position = 0;
using var archive = new ZipArchive(artifactZip, ZipArchiveMode.Read, leaveOpen: true);
var entries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.OrderBy(x => x.FullName, StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(entry.Name) || !entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
await using var stream = entry.Open();
using var reader = new StreamReader(stream);
entries[NormalizePath(entry.FullName)] = await reader.ReadToEndAsync(cancellationToken);
}
return entries;
}
private static string? FindRecipeJson(IReadOnlyDictionary<string, string> textEntries)
{
var recipePath = textEntries.Keys
.Where(x => x.StartsWith("payload/recipes/", StringComparison.OrdinalIgnoreCase))
.Where(x => x.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x, StringComparer.Ordinal)
.FirstOrDefault();
if (recipePath is not null)
return textEntries[recipePath];
foreach (var candidate in new[] { "recipe.json", "loom.recipe.json" })
{
if (textEntries.TryGetValue(candidate, out var recipeJson))
return recipeJson;
}
return null;
}
private static async Task<PlatformArtifactDigest> ComputeDigestAsync(
Stream stream,
CancellationToken cancellationToken)
{
stream.Position = 0;
using var sha = SHA256.Create();
var hash = await sha.ComputeHashAsync(stream, cancellationToken);
stream.Position = 0;
return new PlatformArtifactDigest("sha256", Convert.ToHexString(hash).ToLowerInvariant());
}
private static IReadOnlyList<PlatformDiagnostic> ToPlatformDiagnostics(
IReadOnlyList<RecipeDiagnostic> diagnostics,
string? fallbackError)
{
var platformDiagnostics = diagnostics.Select(ToPlatformDiagnostic).ToList();
if (platformDiagnostics.Count == 0 && !string.IsNullOrWhiteSpace(fallbackError))
platformDiagnostics.Add(PlatformDiagnosticSanitizer.Error("elsa-platform.recipe-failed", fallbackError));
return platformDiagnostics;
}
private static PlatformDiagnostic ToPlatformDiagnostic(RecipeDiagnostic diagnostic)
{
var message = diagnostic.ExceptionSummary is null
? diagnostic.Message
: $"{diagnostic.Message} {diagnostic.ExceptionSummary}";
return diagnostic.Severity switch
{
DiagnosticSeverity.Information => PlatformDiagnosticSanitizer.Info(diagnostic.Code, message),
DiagnosticSeverity.Warning => PlatformDiagnosticSanitizer.Warning(diagnostic.Code, message),
_ => PlatformDiagnosticSanitizer.Error(diagnostic.Code, message)
};
}
private static bool DigestEquals(PlatformArtifactDigest left, PlatformArtifactDigest right) =>
left.Algorithm.Equals(right.Algorithm, StringComparison.OrdinalIgnoreCase)
&& left.Value.Equals(right.Value, StringComparison.OrdinalIgnoreCase);
private static PlatformRecipeArtifactApplyResult Rejected(
PlatformArtifactDigest observedDigest,
string code,
string message) =>
new(PlatformArtifactStatus.Rejected, observedDigest, null, [PlatformDiagnosticSanitizer.Error(code, message)]);
private static string RuntimeReference(Recipe recipe) =>
recipe.Version is null
? $"elsa://loom-recipes/{Uri.EscapeDataString(recipe.Name)}"
: $"elsa://loom-recipes/{Uri.EscapeDataString(recipe.Name)}@{Uri.EscapeDataString(recipe.Version)}";
private static string NormalizePath(string path) =>
path.Replace('\\', '/').TrimStart('/');
}

View file

@ -0,0 +1,157 @@
using Elsa.Platform.Integration.Models;
using Elsa.Platform.Integration.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Platform.Integration.Services;
public class ElsaPlatformDeploymentWorker(
IServiceScopeFactory scopeFactory,
IOptions<ElsaPlatformIntegrationOptions> options,
ILogger<ElsaPlatformDeploymentWorker> logger) : BackgroundService
{
private readonly ElsaPlatformIntegrationOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_options.Validate();
if (!_options.Enabled)
return;
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessAvailableCommandsAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
logger.LogError(ex, "Elsa Platform deployment worker failed while polling commands.");
}
await Task.Delay(_options.PollInterval, stoppingToken);
}
}
private async Task ProcessAvailableCommandsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IPlatformRuntimeCommandClient>();
var applier = scope.ServiceProvider.GetRequiredService<IPlatformRecipeArtifactApplier>();
var commands = await client.PollAsync(cancellationToken);
foreach (var command in commands)
{
var claim = await client.ClaimAsync(command.Id, cancellationToken);
if (claim is null)
continue;
await ProcessClaimedCommandAsync(client, applier, claim, cancellationToken);
}
}
private async Task ProcessClaimedCommandAsync(
IPlatformRuntimeCommandClient client,
IPlatformRecipeArtifactApplier applier,
PlatformRuntimeCommandClaimResponse claim,
CancellationToken cancellationToken)
{
var command = claim.Command;
if (command.Action is not PlatformRuntimeCommandAction.Deploy and not PlatformRuntimeCommandAction.Rollback)
{
await client.RejectAsync(
command.Id,
new PlatformRuntimeCommandRejectRequest(
claim.LeaseToken,
[PlatformDiagnosticSanitizer.Error("elsa-platform.command-unsupported", "Runtime command action is not supported by this Elsa runtime.")]),
cancellationToken);
return;
}
if (command.Artifacts is not { Count: > 0 })
{
await client.RejectAsync(
command.Id,
new PlatformRuntimeCommandRejectRequest(
claim.LeaseToken,
[PlatformDiagnosticSanitizer.Error("elsa-platform.artifact-missing", "Runtime command did not include deployment artifacts.")]),
cancellationToken);
return;
}
var outcomes = new List<PlatformArtifactOutcome>();
try
{
foreach (var artifact in command.Artifacts)
{
await client.ReportProgressAsync(command.Id, claim.LeaseToken, "downloading", 20, "Downloading recipe artifact.", cancellationToken);
await using var artifactZip = await client.DownloadArtifactAsync(command, artifact, claim.LeaseToken, cancellationToken);
await client.ReportProgressAsync(command.Id, claim.LeaseToken, "applying", 60, "Applying recipe artifact.", cancellationToken);
var result = await applier.ApplyAsync(command, artifact, artifactZip, cancellationToken);
outcomes.Add(new PlatformArtifactOutcome(
artifact.ArtifactRecordId,
result.Status,
result.ObservedDigest,
result.RuntimeReference,
result.Diagnostics));
if (!result.Succeeded)
break;
}
var failed = outcomes.FirstOrDefault(x => x.Status == PlatformArtifactStatus.Failed);
if (failed is not null)
{
await client.FailAsync(
command.Id,
new PlatformRuntimeCommandFailRequest(
claim.LeaseToken,
failed.Diagnostics ?? [PlatformDiagnosticSanitizer.Error("elsa-platform.artifact-failed", "Recipe artifact apply failed.")],
outcomes),
cancellationToken);
return;
}
var rejected = outcomes.FirstOrDefault(x => x.Status == PlatformArtifactStatus.Rejected);
if (rejected is not null)
{
await client.RejectAsync(
command.Id,
new PlatformRuntimeCommandRejectRequest(
claim.LeaseToken,
rejected.Diagnostics ?? [PlatformDiagnosticSanitizer.Error("elsa-platform.artifact-rejected", "Recipe artifact apply was rejected.")],
outcomes),
cancellationToken);
return;
}
var first = outcomes.FirstOrDefault();
await client.CompleteAsync(
command.Id,
new PlatformRuntimeCommandCompleteRequest(
claim.LeaseToken,
first?.ObservedDigest,
first?.RuntimeReference,
[PlatformDiagnosticSanitizer.Info("elsa-platform.command-completed", "Recipe deployment command completed.")],
outcomes),
cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Elsa Platform deployment worker failed to apply command {CommandId}.", command.Id);
await client.FailAsync(
command.Id,
new PlatformRuntimeCommandFailRequest(
claim.LeaseToken,
[PlatformDiagnosticSanitizer.Error("elsa-platform.command-failed", ex.Message)],
outcomes),
cancellationToken);
}
}
}

View file

@ -0,0 +1,148 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Elsa.Platform.Integration.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Elsa.Platform.Integration.Services;
public sealed class FileShellConfigurationOverlayStore(
IOptions<ElsaPlatformIntegrationOptions> options,
IHostEnvironment environment) : IShellConfigurationOverlayStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<bool> ConfigureFeaturesAsync(
string shellId,
IReadOnlyDictionary<string, JsonElement>? enabledFeatures,
IReadOnlyList<string>? disabledFeatures,
CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
var root = await LoadAsync(cancellationToken);
var before = Serialize(root);
var features = EnsureObject(root, "CShells", "Shells", shellId, "Features");
foreach (var feature in enabledFeatures ?? new Dictionary<string, JsonElement>())
features[feature.Key] = ToNode(feature.Value) ?? new JsonObject();
foreach (var featureId in disabledFeatures ?? [])
features.Remove(featureId);
var state = EnsureObject(root, "Elsa", "PlatformIntegration", "Shells", shellId);
var disabledFeatureArray = new JsonArray();
foreach (var featureId in disabledFeatures ?? [])
disabledFeatureArray.Add(featureId);
state["DisabledFeatures"] = disabledFeatureArray;
return await SaveIfChangedAsync(root, before, cancellationToken);
}
finally
{
_semaphore.Release();
}
}
public async Task<bool> ConfigureSettingsAsync(
string shellId,
JsonElement settings,
CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
var root = await LoadAsync(cancellationToken);
var before = Serialize(root);
var configuration = EnsureObject(root, "CShells", "Shells", shellId, "Configuration");
if (ToNode(settings) is JsonObject settingsObject)
Merge(configuration, settingsObject);
return await SaveIfChangedAsync(root, before, cancellationToken);
}
finally
{
_semaphore.Release();
}
}
private async Task<JsonObject> LoadAsync(CancellationToken cancellationToken)
{
var path = GetPath();
if (!File.Exists(path))
return new JsonObject();
await using var stream = File.OpenRead(path);
return await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject
?? throw new InvalidOperationException("The Platform shell overlay file must contain a JSON object.");
}
private async Task<bool> SaveIfChangedAsync(JsonObject root, string before, CancellationToken cancellationToken)
{
var after = Serialize(root);
if (string.Equals(before, after, StringComparison.Ordinal))
return false;
var path = GetPath();
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
var tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
await File.WriteAllTextAsync(tempPath, after, cancellationToken);
File.Move(tempPath, path, overwrite: true);
return true;
}
private string GetPath()
{
var path = options.Value.ShellOverlayPath;
return Path.IsPathRooted(path) ? path : Path.Combine(environment.ContentRootPath, path);
}
private static JsonObject EnsureObject(JsonObject root, params string[] path)
{
JsonObject current = root;
foreach (var segment in path)
{
if (current[segment] is not JsonObject child)
{
child = new JsonObject();
current[segment] = child;
}
current = child;
}
return current;
}
private static JsonNode? ToNode(JsonElement element) =>
element.ValueKind switch
{
JsonValueKind.Undefined or JsonValueKind.Null => null,
_ => JsonNode.Parse(element.GetRawText())
};
private static void Merge(JsonObject target, JsonObject source)
{
foreach (var property in source)
{
if (property.Value is JsonObject sourceObject && target[property.Key] is JsonObject targetObject)
{
Merge(targetObject, sourceObject);
continue;
}
target[property.Key] = property.Value?.DeepClone();
}
}
private static string Serialize(JsonObject root) =>
root.ToJsonString(JsonOptions);
}

View file

@ -0,0 +1,21 @@
using Elsa.Platform.Integration.Models;
namespace Elsa.Platform.Integration.Services;
public interface IPlatformRecipeArtifactApplier
{
Task<PlatformRecipeArtifactApplyResult> ApplyAsync(
PlatformRuntimeCommand command,
PlatformArtifactItem artifact,
Stream artifactZip,
CancellationToken cancellationToken = default);
}
public sealed record PlatformRecipeArtifactApplyResult(
PlatformArtifactStatus Status,
PlatformArtifactDigest? ObservedDigest,
string? RuntimeReference,
IReadOnlyList<PlatformDiagnostic> Diagnostics)
{
public bool Succeeded => Status == PlatformArtifactStatus.Applied;
}

View file

@ -0,0 +1,20 @@
using Elsa.Platform.Integration.Models;
namespace Elsa.Platform.Integration.Services;
public interface IPlatformRuntimeCommandClient
{
Task<IReadOnlyList<PlatformRuntimeCommand>> PollAsync(CancellationToken cancellationToken = default);
Task<PlatformRuntimeCommandClaimResponse?> ClaimAsync(Guid commandId, CancellationToken cancellationToken = default);
Task<Stream> DownloadArtifactAsync(PlatformRuntimeCommand command, PlatformArtifactItem artifact, string leaseToken, CancellationToken cancellationToken = default);
Task ReportProgressAsync(Guid commandId, string leaseToken, string status, int? percentComplete, string message, CancellationToken cancellationToken = default);
Task CompleteAsync(Guid commandId, PlatformRuntimeCommandCompleteRequest request, CancellationToken cancellationToken = default);
Task FailAsync(Guid commandId, PlatformRuntimeCommandFailRequest request, CancellationToken cancellationToken = default);
Task RejectAsync(Guid commandId, PlatformRuntimeCommandRejectRequest request, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,17 @@
using System.Text.Json;
namespace Elsa.Platform.Integration.Services;
public interface IShellConfigurationOverlayStore
{
Task<bool> ConfigureFeaturesAsync(
string shellId,
IReadOnlyDictionary<string, JsonElement>? enabledFeatures,
IReadOnlyList<string>? disabledFeatures,
CancellationToken cancellationToken = default);
Task<bool> ConfigureSettingsAsync(
string shellId,
JsonElement settings,
CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,28 @@
using System.Text.RegularExpressions;
using Elsa.Platform.Integration.Models;
namespace Elsa.Platform.Integration.Services;
public static partial class PlatformDiagnosticSanitizer
{
public static PlatformDiagnostic Info(string code, string message) =>
new(code, PlatformDiagnosticSeverity.Info, SafeMessage(message));
public static PlatformDiagnostic Warning(string code, string message) =>
new(code, PlatformDiagnosticSeverity.Warning, SafeMessage(message));
public static PlatformDiagnostic Error(string code, string message) =>
new(code, PlatformDiagnosticSeverity.Error, SafeMessage(message));
public static string SafeMessage(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return "No diagnostic details were provided.";
var safe = SensitiveWordsRegex().Replace(message, "[redacted]");
return safe.Length <= 500 ? safe : safe[..500];
}
[GeneratedRegex("(api[-_ ]?key|bearer|client[-_ ]?secret|password|private[-_ ]?key|secret|token)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SensitiveWordsRegex();
}

View file

@ -0,0 +1,9 @@
namespace Elsa.Platform.Integration.Services;
internal sealed class PlatformRecipeServiceProvider(
IServiceProvider inner,
IReadOnlyDictionary<Type, object> services) : IServiceProvider
{
public object? GetService(Type serviceType) =>
services.TryGetValue(serviceType, out var service) ? service : inner.GetService(serviceType);
}

View file

@ -0,0 +1,149 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Platform.Integration.Models;
using Elsa.Platform.Integration.Options;
using Microsoft.Extensions.Options;
namespace Elsa.Platform.Integration.Services;
public class PlatformRuntimeCommandClient(HttpClient httpClient, IOptions<ElsaPlatformIntegrationOptions> options) : IPlatformRuntimeCommandClient
{
private const string EngineSecretHeaderName = "X-Elsa-Engine-Secret";
private const string LeaseHeaderName = "X-Elsa-Command-Lease";
private const string WorkerHeaderName = "X-Elsa-Worker-Id";
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly ElsaPlatformIntegrationOptions _options = options.Value;
public async Task<IReadOnlyList<PlatformRuntimeCommand>> PollAsync(CancellationToken cancellationToken = default)
{
using var request = CreateRequest(HttpMethod.Get, BuildUri($"/deployments/runtime/engines/{_options.EngineId:D}/commands"));
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
throw new InvalidOperationException("Elsa Platform runtime command poll was not authorized.");
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<PlatformRuntimeCommandListResponse>(JsonOptions, cancellationToken);
return body?.Commands ?? [];
}
public async Task<PlatformRuntimeCommandClaimResponse?> ClaimAsync(Guid commandId, CancellationToken cancellationToken = default)
{
using var response = await SendJsonAsync(
BuildUri($"/deployments/runtime/commands/{commandId:D}/claim"),
new PlatformRuntimeCommandClaimRequest(_options.EngineId, _options.WorkerId, (int)_options.ClaimLeaseDuration.TotalSeconds),
cancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict || response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PlatformRuntimeCommandClaimResponse>(JsonOptions, cancellationToken);
}
public async Task<Stream> DownloadArtifactAsync(
PlatformRuntimeCommand command,
PlatformArtifactItem artifact,
string leaseToken,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(artifact.DownloadUrl))
throw new InvalidOperationException("Platform runtime command artifact does not include a download URL.");
using var request = CreateRequest(HttpMethod.Get, BuildUri(artifact.DownloadUrl));
request.Headers.Add(LeaseHeaderName, leaseToken);
request.Headers.Add(WorkerHeaderName, _options.WorkerId);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var buffer = new MemoryStream();
await CopyBoundedAsync(stream, buffer, cancellationToken);
buffer.Position = 0;
return buffer;
}
public Task ReportProgressAsync(
Guid commandId,
string leaseToken,
string status,
int? percentComplete,
string message,
CancellationToken cancellationToken = default) =>
SendMutationAsync(
commandId,
"progress",
new PlatformRuntimeCommandProgressRequest(leaseToken, status, percentComplete, message),
cancellationToken);
public Task CompleteAsync(Guid commandId, PlatformRuntimeCommandCompleteRequest request, CancellationToken cancellationToken = default) =>
SendMutationAsync(commandId, "complete", request, cancellationToken);
public Task FailAsync(Guid commandId, PlatformRuntimeCommandFailRequest request, CancellationToken cancellationToken = default) =>
SendMutationAsync(commandId, "fail", request, cancellationToken);
public Task RejectAsync(Guid commandId, PlatformRuntimeCommandRejectRequest request, CancellationToken cancellationToken = default) =>
SendMutationAsync(commandId, "reject", request, cancellationToken);
private async Task SendMutationAsync<TRequest>(
Guid commandId,
string action,
TRequest body,
CancellationToken cancellationToken)
{
using var response = await SendJsonAsync(BuildUri($"/deployments/runtime/commands/{commandId:D}/{action}"), body, cancellationToken);
response.EnsureSuccessStatusCode();
}
private HttpRequestMessage CreateRequest(HttpMethod method, Uri uri)
{
var request = new HttpRequestMessage(method, uri);
if (!string.IsNullOrWhiteSpace(_options.EngineSecret))
request.Headers.TryAddWithoutValidation(EngineSecretHeaderName, _options.EngineSecret);
return request;
}
private async Task<HttpResponseMessage> SendJsonAsync<TRequest>(Uri uri, TRequest body, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, uri);
request.Content = JsonContent.Create(body, options: JsonOptions);
return await httpClient.SendAsync(request, cancellationToken);
}
private Uri BuildUri(string path)
{
var endpoint = _options.PlatformEndpoint ?? throw new InvalidOperationException("Elsa Platform endpoint is required.");
if (Uri.TryCreate(path, UriKind.Absolute, out var absoluteUri))
return absoluteUri;
var relative = path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)
? path
: $"/api/workspaces/{_options.WorkspaceId:D}{path}";
return new Uri($"{endpoint.AbsoluteUri.TrimEnd('/')}{relative}");
}
private async Task CopyBoundedAsync(Stream source, Stream destination, CancellationToken cancellationToken)
{
var buffer = new byte[81920];
long total = 0;
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken);
if (read == 0)
return;
total += read;
if (total > _options.MaxArtifactBytes)
throw new InvalidOperationException("Elsa Platform artifact exceeds the configured runtime size limit.");
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
}
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
return options;
}
}

View file

@ -0,0 +1,14 @@
namespace Elsa.Platform.Integration.Services;
public sealed class PlatformShellReloadTracker
{
private readonly HashSet<string> _shellIds = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<string> ShellIds => _shellIds;
public void MarkForReload(string shellId)
{
if (!string.IsNullOrWhiteSpace(shellId))
_shellIds.Add(shellId);
}
}

View file

@ -0,0 +1,29 @@
using CShells.Features;
using Elsa.Platform.Integration.Options;
using Elsa.Platform.Integration.Services;
using Elsa.Platform.PackageManifest.Generator.Hints;
using Elsa.Workflows.Management.ShellFeatures;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Platform.Integration.ShellFeatures;
[ManifestFeatureCategory("Integrations")]
[ShellFeature(
DisplayName = "Elsa Platform Integration",
Description = "Polls Elsa Platform deployment commands and applies Loom recipe artifacts to this Elsa runtime",
DependsOn = [typeof(WorkflowManagementFeature)])]
[UsedImplicitly]
public class ElsaPlatformIntegrationFeature : IShellFeature
{
public void ConfigureServices(IServiceCollection services)
{
services.AddOptions<ElsaPlatformIntegrationOptions>()
.BindConfiguration(ElsaPlatformIntegrationOptions.ConfigurationSection);
services.AddHttpClient<IPlatformRuntimeCommandClient, PlatformRuntimeCommandClient>();
services.AddSingleton<IShellConfigurationOverlayStore, FileShellConfigurationOverlayStore>();
services.AddScoped<IPlatformRecipeArtifactApplier, ElsaLoomRecipeArtifactApplier>();
services.AddHostedService<ElsaPlatformDeploymentWorker>();
}
}

View file

@ -0,0 +1,48 @@
using System.Text.Json;
using Elsa.Platform.Integration.Services;
using Loom;
namespace Elsa.Platform.Integration.Steps;
[Step("elsa.configure-features")]
public sealed class ConfigureFeaturesStep(
IShellConfigurationOverlayStore overlayStore,
PlatformShellReloadTracker reloadTracker) : IStep, IValidatingStep
{
public string ShellId { get; init; } = "Default";
public Dictionary<string, JsonElement>? Enable { get; init; }
public List<string>? Disable { get; init; }
public ValueTask<IReadOnlyList<RecipeDiagnostic>> ValidateAsync(
StepValidationContext context,
CancellationToken cancellationToken = default)
{
List<RecipeDiagnostic> diagnostics = [];
if (string.IsNullOrWhiteSpace(ShellId))
diagnostics.Add(context.Error("ELSA_PLATFORM_SHELL_ID_REQUIRED", "Shell ID is required.", context.Target("input.shellId")));
if ((Enable is null || Enable.Count == 0) && (Disable is null || Disable.Count == 0))
diagnostics.Add(context.Error("ELSA_PLATFORM_FEATURE_CHANGE_REQUIRED", "At least one feature enable or disable entry is required.", context.Target("input")));
foreach (var feature in Enable ?? [])
{
if (feature.Value.ValueKind is not JsonValueKind.Object and not JsonValueKind.Null and not JsonValueKind.Undefined)
diagnostics.Add(context.Error("ELSA_PLATFORM_FEATURE_CONFIG_INVALID", $"Feature '{feature.Key}' configuration must be a JSON object.", context.Target($"input.enable.{feature.Key}")));
}
var duplicate = Enable?.Keys.FirstOrDefault(feature => Disable?.Contains(feature, StringComparer.OrdinalIgnoreCase) == true);
if (duplicate is not null)
diagnostics.Add(context.Error("ELSA_PLATFORM_FEATURE_CHANGE_CONFLICT", $"Feature '{duplicate}' cannot be enabled and disabled in the same step.", context.Target("input")));
return ValueTask.FromResult<IReadOnlyList<RecipeDiagnostic>>(diagnostics);
}
public async ValueTask ExecuteAsync(StepContext context, CancellationToken cancellationToken = default)
{
var changed = await overlayStore.ConfigureFeaturesAsync(ShellId, Enable, Disable, cancellationToken);
if (changed)
reloadTracker.MarkForReload(ShellId);
}
}

View file

@ -0,0 +1,36 @@
using System.Text.Json;
using Elsa.Platform.Integration.Services;
using Loom;
namespace Elsa.Platform.Integration.Steps;
[Step("elsa.configure-settings")]
public sealed class ConfigureSettingsStep(
IShellConfigurationOverlayStore overlayStore,
PlatformShellReloadTracker reloadTracker) : IStep, IValidatingStep
{
public string ShellId { get; init; } = "Default";
public JsonElement Settings { get; init; }
public ValueTask<IReadOnlyList<RecipeDiagnostic>> ValidateAsync(
StepValidationContext context,
CancellationToken cancellationToken = default)
{
List<RecipeDiagnostic> diagnostics = [];
if (string.IsNullOrWhiteSpace(ShellId))
diagnostics.Add(context.Error("ELSA_PLATFORM_SHELL_ID_REQUIRED", "Shell ID is required.", context.Target("input.shellId")));
if (Settings.ValueKind != JsonValueKind.Object)
diagnostics.Add(context.Error("ELSA_PLATFORM_SETTINGS_REQUIRED", "Settings must be a JSON object.", context.Target("input.settings")));
return ValueTask.FromResult<IReadOnlyList<RecipeDiagnostic>>(diagnostics);
}
public async ValueTask ExecuteAsync(StepContext context, CancellationToken cancellationToken = default)
{
var changed = await overlayStore.ConfigureSettingsAsync(ShellId, Settings, cancellationToken);
if (changed)
reloadTracker.MarkForReload(ShellId);
}
}

View file

@ -0,0 +1,97 @@
using System.Text.Json;
using Elsa.Platform.Integration.Models;
using Elsa.Platform.Integration.Services;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Models;
using Loom;
namespace Elsa.Platform.Integration.Steps;
[Step("elsa.import-workflow-definition")]
public sealed class ImportWorkflowDefinitionStep(
IWorkflowDefinitionImporter importer,
IApiSerializer apiSerializer,
PlatformRecipeArtifact artifact) : IStep, IValidatingStep
{
public JsonElement WorkflowDefinition { get; init; }
public string? Path { get; init; }
public bool Publish { get; init; } = true;
public ValueTask<IReadOnlyList<RecipeDiagnostic>> ValidateAsync(
StepValidationContext context,
CancellationToken cancellationToken = default)
{
var hasInlineWorkflow = WorkflowDefinition.ValueKind == JsonValueKind.Object;
var hasPath = !string.IsNullOrWhiteSpace(Path);
if (!hasInlineWorkflow && !hasPath)
{
return ValueTask.FromResult<IReadOnlyList<RecipeDiagnostic>>(
[
context.Error(
"ELSA_PLATFORM_WORKFLOW_DEFINITION_MISSING",
"A workflow definition or artifact-relative path is required.",
context.Target("input.workflowDefinition"))
]);
}
if (hasPath && !artifact.TryGetText(Path!, out _))
{
return ValueTask.FromResult<IReadOnlyList<RecipeDiagnostic>>(
[
context.Error(
"ELSA_PLATFORM_WORKFLOW_DEFINITION_FILE_MISSING",
$"Workflow definition file '{Path}' was not found in the recipe artifact.",
context.Target("input.path"))
]);
}
return ValueTask.FromResult<IReadOnlyList<RecipeDiagnostic>>([]);
}
public async ValueTask ExecuteAsync(StepContext context, CancellationToken cancellationToken = default)
{
var workflowJson = ResolveWorkflowJson();
WorkflowDefinitionModel model;
try
{
model = apiSerializer.Deserialize<WorkflowDefinitionModel>(workflowJson);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)
{
throw new InvalidOperationException("Workflow definition payload is invalid.", ex);
}
var importResult = await importer.ImportAsync(new SaveWorkflowDefinitionRequest
{
Model = model,
Publish = Publish
}, cancellationToken);
if (!importResult.Succeeded)
{
var errors = string.Join("; ", importResult.ValidationErrors.Select(x => x.Message));
throw new InvalidOperationException($"Workflow definition validation failed: {errors}");
}
context.Log($"Workflow definition '{model.DefinitionId}' was imported.");
}
private string ResolveWorkflowJson()
{
if (!string.IsNullOrWhiteSpace(Path))
{
if (artifact.TryGetText(Path, out var content))
return content;
throw new InvalidOperationException($"Workflow definition file '{Path}' was not found in the recipe artifact.");
}
if (WorkflowDefinition.ValueKind == JsonValueKind.Object)
return WorkflowDefinition.GetRawText();
throw new InvalidOperationException("A workflow definition or artifact-relative path is required.");
}
}

View file

@ -0,0 +1,44 @@
using Elsa.Features.Contracts;
using Elsa.Platform.Integration.Options;
using Loom;
using Microsoft.Extensions.Options;
namespace Elsa.Platform.Integration.Steps;
[Step("elsa.verify-capabilities")]
public sealed class VerifyCapabilitiesStep(
IInstalledFeatureProvider installedFeatureProvider,
IOptions<ElsaPlatformIntegrationOptions> options) : IStep, IValidatingStep
{
public List<string>? Features { get; init; }
public List<string>? Capabilities { get; init; }
public ValueTask<IReadOnlyList<RecipeDiagnostic>> ValidateAsync(
StepValidationContext context,
CancellationToken cancellationToken = default)
{
List<RecipeDiagnostic> diagnostics = [];
var installedFeatures = installedFeatureProvider.List().ToList();
foreach (var feature in Features ?? [])
{
var exists = installedFeatures.Any(x =>
string.Equals(x.Name, feature, StringComparison.OrdinalIgnoreCase)
|| string.Equals(x.FullName, feature, StringComparison.OrdinalIgnoreCase));
if (!exists)
diagnostics.Add(context.Error("ELSA_PLATFORM_FEATURE_MISSING", $"Required feature '{feature}' is not installed.", context.Target("input.features")));
}
var runtimeCapabilities = options.Value.Capabilities;
foreach (var capability in Capabilities ?? [])
{
if (!runtimeCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))
diagnostics.Add(context.Error("ELSA_PLATFORM_CAPABILITY_MISSING", $"Required runtime capability '{capability}' is not available.", context.Target("input.capabilities")));
}
return ValueTask.FromResult<IReadOnlyList<RecipeDiagnostic>>(diagnostics);
}
public ValueTask ExecuteAsync(StepContext context, CancellationToken cancellationToken = default) =>
ValueTask.CompletedTask;
}

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Platform.Integration\Elsa.Platform.Integration.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,172 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using CShells.Lifecycle;
using Elsa.Platform.Integration.Models;
using Elsa.Platform.Integration.Options;
using Elsa.Platform.Integration.Services;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Elsa.Platform.Integration.UnitTests;
public class ElsaLoomRecipeArtifactApplierTests
{
[Fact]
public async Task ApplyAsync_WithInlineImportWorkflowDefinitionRecipe_ImportsWorkflowDefinition()
{
var importer = Substitute.For<IWorkflowDefinitionImporter>();
var apiSerializer = Substitute.For<IApiSerializer>();
var shellRegistry = Substitute.For<IShellRegistry>();
SaveWorkflowDefinitionRequest? importedRequest = null;
var model = new WorkflowDefinitionModel
{
DefinitionId = "workflow-1",
Name = "Workflow 1"
};
apiSerializer.Deserialize<WorkflowDefinitionModel>(Arg.Any<string>()).Returns(model);
importer.ImportAsync(Arg.Do<SaveWorkflowDefinitionRequest>(x => importedRequest = x), Arg.Any<CancellationToken>())
.Returns(new ImportWorkflowResult(true, new WorkflowDefinition { DefinitionId = "workflow-1" }, []));
var services = new ServiceCollection()
.AddSingleton(importer)
.AddSingleton(apiSerializer)
.AddSingleton(Microsoft.Extensions.Options.Options.Create(new ElsaPlatformIntegrationOptions()))
.BuildServiceProvider();
var sut = new ElsaLoomRecipeArtifactApplier(services, shellRegistry);
await using var artifactZip = CreateArtifact("""
{
"name": "deploy-workflow",
"steps": [
{
"id": "import-workflow",
"type": "elsa.import-workflow-definition",
"input": {
"workflowDefinition": {
"definitionId": "workflow-1",
"name": "Workflow 1"
}
}
}
]
}
""");
var artifact = CreateArtifactItem(artifactZip);
var result = await sut.ApplyAsync(CreateCommand(artifact), artifact, artifactZip);
Assert.True(result.Succeeded);
Assert.NotNull(importedRequest);
Assert.True(importedRequest.Publish);
Assert.Same(model, importedRequest.Model);
await shellRegistry.DidNotReceiveWithAnyArgs().ReloadAsync(default!, default);
}
[Fact]
public async Task ApplyAsync_WithMissingCapability_ReturnsRejected()
{
var services = new ServiceCollection()
.AddSingleton(Substitute.For<IWorkflowDefinitionImporter>())
.AddSingleton(Substitute.For<IApiSerializer>())
.AddSingleton(Microsoft.Extensions.Options.Options.Create(new ElsaPlatformIntegrationOptions()))
.AddSingleton(Substitute.For<Elsa.Features.Contracts.IInstalledFeatureProvider>())
.BuildServiceProvider();
var sut = new ElsaLoomRecipeArtifactApplier(services, Substitute.For<IShellRegistry>());
await using var artifactZip = CreateArtifact("""
{
"name": "verify-capability",
"steps": [
{
"id": "verify",
"type": "elsa.verify-capabilities",
"input": {
"capabilities": [ "missing.capability" ]
}
}
]
}
""");
var artifact = CreateArtifactItem(artifactZip);
var result = await sut.ApplyAsync(CreateCommand(artifact), artifact, artifactZip);
Assert.Equal(PlatformArtifactStatus.Rejected, result.Status);
Assert.Contains(result.Diagnostics, x => x.Code == "ELSA_PLATFORM_CAPABILITY_MISSING");
}
private static MemoryStream CreateArtifact(string recipeJson)
{
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry("payload/recipes/recipe.json");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
writer.Write(recipeJson);
}
stream.Position = 0;
return stream;
}
private static PlatformArtifactItem CreateArtifactItem(Stream artifactZip)
{
var digest = ComputeDigest(artifactZip);
return new PlatformArtifactItem(
Guid.NewGuid(),
"recipe",
"loom.recipe",
"1.0",
digest,
"Recipe",
"https://example.com/recipe.zip",
PlatformArtifactStatus.Pending,
null,
null,
null);
}
private static PlatformRuntimeCommand CreateCommand(PlatformArtifactItem artifact) =>
new(
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
PlatformRuntimeCommandAction.Deploy,
PlatformRuntimeCommandStatus.Running,
null,
null,
"test",
null,
null,
null,
null,
1,
null,
null,
null,
null,
[],
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
null,
null,
[artifact]);
private static PlatformArtifactDigest ComputeDigest(Stream stream)
{
stream.Position = 0;
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
stream.Position = 0;
return new PlatformArtifactDigest("sha256", Convert.ToHexString(hash).ToLowerInvariant());
}
}

View file

@ -0,0 +1,76 @@
using System.Text.Json.Nodes;
using Elsa.Platform.Integration.Options;
using Elsa.Platform.Integration.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Elsa.Platform.Integration.UnitTests;
public class FileShellConfigurationOverlayStoreTests : IAsyncLifetime
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"elsa-platform-overlay-{Guid.NewGuid():N}");
private readonly FileShellConfigurationOverlayStore _store;
private readonly string _overlayPath;
public FileShellConfigurationOverlayStoreTests()
{
_overlayPath = Path.Combine(_directory, "platform-shell-overrides.json");
var environment = Substitute.For<IHostEnvironment>();
environment.ContentRootPath.Returns(_directory);
_store = new FileShellConfigurationOverlayStore(
Microsoft.Extensions.Options.Options.Create(new ElsaPlatformIntegrationOptions
{
ShellOverlayPath = "platform-shell-overrides.json"
}),
environment);
}
[Fact]
public async Task ConfigureFeaturesAsync_WritesEnabledAndDisabledFeatureState()
{
var enabled = new Dictionary<string, System.Text.Json.JsonElement>
{
["Http"] = System.Text.Json.JsonDocument.Parse("""{ "baseUrl": "https://example.com" }""").RootElement.Clone()
};
var changed = await _store.ConfigureFeaturesAsync("Default", enabled, ["Python"]);
Assert.True(changed);
var root = await ReadRootAsync();
Assert.Equal("https://example.com", root["CShells"]!["Shells"]!["Default"]!["Features"]!["Http"]!["baseUrl"]!.GetValue<string>());
Assert.Equal("Python", root["Elsa"]!["PlatformIntegration"]!["Shells"]!["Default"]!["DisabledFeatures"]![0]!.GetValue<string>());
}
[Fact]
public async Task ConfigureSettingsAsync_MergesShellConfiguration()
{
var settings = System.Text.Json.JsonDocument.Parse("""{ "WebRouting": { "Path": "tenant-a" } }""").RootElement.Clone();
var changed = await _store.ConfigureSettingsAsync("Default", settings);
Assert.True(changed);
var root = await ReadRootAsync();
Assert.Equal("tenant-a", root["CShells"]!["Shells"]!["Default"]!["Configuration"]!["WebRouting"]!["Path"]!.GetValue<string>());
}
public Task InitializeAsync()
{
Directory.CreateDirectory(_directory);
return Task.CompletedTask;
}
public Task DisposeAsync()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
return Task.CompletedTask;
}
private async Task<JsonNode> ReadRootAsync()
{
await using var stream = File.OpenRead(_overlayPath);
return (await JsonNode.ParseAsync(stream))!;
}
}