Add Platform integration
This commit is contained in:
parent
20c1064ca5
commit
e9d89969b7
|
|
@ -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"/>
|
||||
|
|
|
|||
33
Elsa.sln
33
Elsa.sln
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
3
src/modules/Elsa.Platform.Integration/FodyWeavers.xml
Normal file
3
src/modules/Elsa.Platform.Integration/FodyWeavers.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
|
|
@ -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('/');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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('/');
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Platform.Integration\Elsa.Platform.Integration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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))!;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue