< Summary

Information
Class: Elsa.Platform.Integration.Services.ElsaLoomRecipeArtifactApplier
Assembly: Elsa.Platform.Integration
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Platform.Integration/Services/ElsaLoomRecipeArtifactApplier.cs
Line coverage
77%
Covered lines: 91
Uncovered lines: 26
Coverable lines: 117
Total lines: 209
Line coverage: 77.7%
Branch coverage
54%
Covered branches: 23
Total branches: 42
Branch coverage: 54.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
ApplyAsync()58.33%141276.78%
ReloadShellsAsync()25%9433.33%
ReadTextEntriesAsync()100%88100%
FindRecipeJson(...)16.66%8663.63%
ComputeDigestAsync()100%11100%
ToPlatformDiagnostics(...)50%4475%
ToPlatformDiagnostic(...)66.66%6688.88%
DigestEquals(...)50%22100%
Rejected(...)100%210%
RuntimeReference(...)50%22100%
NormalizePath(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Platform.Integration/Services/ElsaLoomRecipeArtifactApplier.cs

#LineLine coverage
 1using System.IO.Compression;
 2using System.Security.Cryptography;
 3using CShells.Lifecycle;
 4using Elsa.Platform.Integration.Models;
 5using Elsa.Platform.Integration.Steps;
 6using Loom;
 7
 8namespace Elsa.Platform.Integration.Services;
 9
 210public class ElsaLoomRecipeArtifactApplier(
 211    IServiceProvider serviceProvider,
 212    IShellRegistry shellRegistry) : IPlatformRecipeArtifactApplier
 13{
 114    private static readonly JsonRecipeSerializer Serializer = new();
 15
 16    public async Task<PlatformRecipeArtifactApplyResult> ApplyAsync(
 17        PlatformRuntimeCommand command,
 18        PlatformArtifactItem artifact,
 19        Stream artifactZip,
 20        CancellationToken cancellationToken = default)
 21    {
 222        var observedDigest = await ComputeDigestAsync(artifactZip, cancellationToken);
 223        if (!DigestEquals(observedDigest, artifact.ContentDigest))
 24        {
 025            return Rejected(
 026                observedDigest,
 027                "elsa-platform.artifact-digest-mismatch",
 028                "Downloaded recipe artifact digest did not match the Platform command digest.");
 29        }
 30
 231        var textEntries = await ReadTextEntriesAsync(artifactZip, cancellationToken);
 232        var recipeJson = FindRecipeJson(textEntries);
 233        if (recipeJson is null)
 034            return Rejected(observedDigest, "elsa-platform.recipe-payload-missing", "Loom recipe artifact ZIP did not co
 35
 36        Recipe recipe;
 37        try
 38        {
 239            recipe = Serializer.Deserialize(recipeJson);
 240        }
 041        catch (RecipeSerializationException ex)
 42        {
 043            return Rejected(observedDigest, "elsa-platform.recipe-payload-invalid", ex.Message);
 44        }
 45
 246        var reloadTracker = new PlatformShellReloadTracker();
 247        var recipeServices = new PlatformRecipeServiceProvider(
 248            serviceProvider,
 249            new Dictionary<Type, object>
 250            {
 251                [typeof(PlatformRecipeArtifact)] = new PlatformRecipeArtifact(textEntries),
 252                [typeof(PlatformShellReloadTracker)] = reloadTracker
 253            });
 54
 255        var engine = RecipeEngine.Create()
 256            .RegisterStep<VerifyCapabilitiesStep>()
 257            .RegisterStep<ImportWorkflowDefinitionStep>()
 258            .RegisterStep<ConfigureFeaturesStep>()
 259            .RegisterStep<ConfigureSettingsStep>();
 60
 261        var runResult = await engine.RunAsync(recipe, new RecipeRunOptions
 262        {
 263            Services = recipeServices
 264        }, cancellationToken);
 65
 266        if (!runResult.Succeeded)
 67        {
 168            var status = runResult.Status == RecipeRunStatus.ValidationFailed
 169                ? PlatformArtifactStatus.Rejected
 170                : PlatformArtifactStatus.Failed;
 171            return new PlatformRecipeArtifactApplyResult(
 172                status,
 173                observedDigest,
 174                RuntimeReference(recipe),
 175                ToPlatformDiagnostics(runResult.Diagnostics, runResult.Error));
 76        }
 77
 178        var reloadFailure = await ReloadShellsAsync(reloadTracker, cancellationToken);
 179        if (reloadFailure is not null)
 80        {
 081            return new PlatformRecipeArtifactApplyResult(
 082                PlatformArtifactStatus.Failed,
 083                observedDigest,
 084                RuntimeReference(recipe),
 085                [reloadFailure]);
 86        }
 87
 188        var diagnostics = ToPlatformDiagnostics(runResult.Diagnostics, null);
 189        if (diagnostics.Count == 0)
 090            diagnostics = [PlatformDiagnosticSanitizer.Info("elsa-platform.recipe-applied", "Loom recipe artifact was ap
 91
 192        return new PlatformRecipeArtifactApplyResult(
 193            PlatformArtifactStatus.Applied,
 194            observedDigest,
 195            RuntimeReference(recipe),
 196            diagnostics);
 297    }
 98
 99    private async Task<PlatformDiagnostic?> ReloadShellsAsync(
 100        PlatformShellReloadTracker reloadTracker,
 101        CancellationToken cancellationToken)
 102    {
 2103        foreach (var shellId in reloadTracker.ShellIds)
 104        {
 0105            var result = await shellRegistry.ReloadAsync(shellId, cancellationToken);
 0106            if (result.Error is not null)
 107            {
 0108                return PlatformDiagnosticSanitizer.Error(
 0109                    "elsa-platform.shell-reload-failed",
 0110                    $"Shell '{shellId}' reload failed: {result.Error.Message}");
 111            }
 0112        }
 113
 1114        return null;
 1115    }
 116
 117    private static async Task<IReadOnlyDictionary<string, string>> ReadTextEntriesAsync(
 118        Stream artifactZip,
 119        CancellationToken cancellationToken)
 120    {
 2121        artifactZip.Position = 0;
 2122        using var archive = new ZipArchive(artifactZip, ZipArchiveMode.Read, leaveOpen: true);
 2123        var entries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 10124        foreach (var entry in archive.Entries.OrderBy(x => x.FullName, StringComparer.Ordinal))
 125        {
 2126            if (string.IsNullOrWhiteSpace(entry.Name) || !entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnor
 127                continue;
 128
 2129            await using var stream = entry.Open();
 2130            using var reader = new StreamReader(stream);
 2131            entries[NormalizePath(entry.FullName)] = await reader.ReadToEndAsync(cancellationToken);
 2132        }
 133
 2134        return entries;
 2135    }
 136
 137    private static string? FindRecipeJson(IReadOnlyDictionary<string, string> textEntries)
 138    {
 2139        var recipePath = textEntries.Keys
 2140            .Where(x => x.StartsWith("payload/recipes/", StringComparison.OrdinalIgnoreCase))
 2141            .Where(x => x.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
 2142            .OrderBy(x => x, StringComparer.Ordinal)
 2143            .FirstOrDefault();
 144
 2145        if (recipePath is not null)
 2146            return textEntries[recipePath];
 147
 0148        foreach (var candidate in new[] { "recipe.json", "loom.recipe.json" })
 149        {
 0150            if (textEntries.TryGetValue(candidate, out var recipeJson))
 0151                return recipeJson;
 152        }
 153
 0154        return null;
 155    }
 156
 157    private static async Task<PlatformArtifactDigest> ComputeDigestAsync(
 158        Stream stream,
 159        CancellationToken cancellationToken)
 160    {
 2161        stream.Position = 0;
 2162        using var sha = SHA256.Create();
 2163        var hash = await sha.ComputeHashAsync(stream, cancellationToken);
 2164        stream.Position = 0;
 2165        return new PlatformArtifactDigest("sha256", Convert.ToHexString(hash).ToLowerInvariant());
 2166    }
 167
 168    private static IReadOnlyList<PlatformDiagnostic> ToPlatformDiagnostics(
 169        IReadOnlyList<RecipeDiagnostic> diagnostics,
 170        string? fallbackError)
 171    {
 2172        var platformDiagnostics = diagnostics.Select(ToPlatformDiagnostic).ToList();
 2173        if (platformDiagnostics.Count == 0 && !string.IsNullOrWhiteSpace(fallbackError))
 0174            platformDiagnostics.Add(PlatformDiagnosticSanitizer.Error("elsa-platform.recipe-failed", fallbackError));
 175
 2176        return platformDiagnostics;
 177    }
 178
 179    private static PlatformDiagnostic ToPlatformDiagnostic(RecipeDiagnostic diagnostic)
 180    {
 2181        var message = diagnostic.ExceptionSummary is null
 2182            ? diagnostic.Message
 2183            : $"{diagnostic.Message} {diagnostic.ExceptionSummary}";
 2184        return diagnostic.Severity switch
 2185        {
 1186            DiagnosticSeverity.Information => PlatformDiagnosticSanitizer.Info(diagnostic.Code, message),
 0187            DiagnosticSeverity.Warning => PlatformDiagnosticSanitizer.Warning(diagnostic.Code, message),
 1188            _ => PlatformDiagnosticSanitizer.Error(diagnostic.Code, message)
 2189        };
 190    }
 191
 192    private static bool DigestEquals(PlatformArtifactDigest left, PlatformArtifactDigest right) =>
 2193        left.Algorithm.Equals(right.Algorithm, StringComparison.OrdinalIgnoreCase)
 2194        && left.Value.Equals(right.Value, StringComparison.OrdinalIgnoreCase);
 195
 196    private static PlatformRecipeArtifactApplyResult Rejected(
 197        PlatformArtifactDigest observedDigest,
 198        string code,
 199        string message) =>
 0200        new(PlatformArtifactStatus.Rejected, observedDigest, null, [PlatformDiagnosticSanitizer.Error(code, message)]);
 201
 202    private static string RuntimeReference(Recipe recipe) =>
 2203        recipe.Version is null
 2204            ? $"elsa://loom-recipes/{Uri.EscapeDataString(recipe.Name)}"
 2205            : $"elsa://loom-recipes/{Uri.EscapeDataString(recipe.Name)}@{Uri.EscapeDataString(recipe.Version)}";
 206
 207    private static string NormalizePath(string path) =>
 2208        path.Replace('\\', '/').TrimStart('/');
 209}