| | | 1 | | using System.IO.Compression; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using CShells.Lifecycle; |
| | | 4 | | using Elsa.Platform.Integration.Models; |
| | | 5 | | using Elsa.Platform.Integration.Steps; |
| | | 6 | | using Loom; |
| | | 7 | | |
| | | 8 | | namespace Elsa.Platform.Integration.Services; |
| | | 9 | | |
| | 2 | 10 | | public class ElsaLoomRecipeArtifactApplier( |
| | 2 | 11 | | IServiceProvider serviceProvider, |
| | 2 | 12 | | IShellRegistry shellRegistry) : IPlatformRecipeArtifactApplier |
| | | 13 | | { |
| | 1 | 14 | | 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 | | { |
| | 2 | 22 | | var observedDigest = await ComputeDigestAsync(artifactZip, cancellationToken); |
| | 2 | 23 | | if (!DigestEquals(observedDigest, artifact.ContentDigest)) |
| | | 24 | | { |
| | 0 | 25 | | return Rejected( |
| | 0 | 26 | | observedDigest, |
| | 0 | 27 | | "elsa-platform.artifact-digest-mismatch", |
| | 0 | 28 | | "Downloaded recipe artifact digest did not match the Platform command digest."); |
| | | 29 | | } |
| | | 30 | | |
| | 2 | 31 | | var textEntries = await ReadTextEntriesAsync(artifactZip, cancellationToken); |
| | 2 | 32 | | var recipeJson = FindRecipeJson(textEntries); |
| | 2 | 33 | | if (recipeJson is null) |
| | 0 | 34 | | return Rejected(observedDigest, "elsa-platform.recipe-payload-missing", "Loom recipe artifact ZIP did not co |
| | | 35 | | |
| | | 36 | | Recipe recipe; |
| | | 37 | | try |
| | | 38 | | { |
| | 2 | 39 | | recipe = Serializer.Deserialize(recipeJson); |
| | 2 | 40 | | } |
| | 0 | 41 | | catch (RecipeSerializationException ex) |
| | | 42 | | { |
| | 0 | 43 | | return Rejected(observedDigest, "elsa-platform.recipe-payload-invalid", ex.Message); |
| | | 44 | | } |
| | | 45 | | |
| | 2 | 46 | | var reloadTracker = new PlatformShellReloadTracker(); |
| | 2 | 47 | | var recipeServices = new PlatformRecipeServiceProvider( |
| | 2 | 48 | | serviceProvider, |
| | 2 | 49 | | new Dictionary<Type, object> |
| | 2 | 50 | | { |
| | 2 | 51 | | [typeof(PlatformRecipeArtifact)] = new PlatformRecipeArtifact(textEntries), |
| | 2 | 52 | | [typeof(PlatformShellReloadTracker)] = reloadTracker |
| | 2 | 53 | | }); |
| | | 54 | | |
| | 2 | 55 | | var engine = RecipeEngine.Create() |
| | 2 | 56 | | .RegisterStep<VerifyCapabilitiesStep>() |
| | 2 | 57 | | .RegisterStep<ImportWorkflowDefinitionStep>() |
| | 2 | 58 | | .RegisterStep<ConfigureFeaturesStep>() |
| | 2 | 59 | | .RegisterStep<ConfigureSettingsStep>(); |
| | | 60 | | |
| | 2 | 61 | | var runResult = await engine.RunAsync(recipe, new RecipeRunOptions |
| | 2 | 62 | | { |
| | 2 | 63 | | Services = recipeServices |
| | 2 | 64 | | }, cancellationToken); |
| | | 65 | | |
| | 2 | 66 | | if (!runResult.Succeeded) |
| | | 67 | | { |
| | 1 | 68 | | var status = runResult.Status == RecipeRunStatus.ValidationFailed |
| | 1 | 69 | | ? PlatformArtifactStatus.Rejected |
| | 1 | 70 | | : PlatformArtifactStatus.Failed; |
| | 1 | 71 | | return new PlatformRecipeArtifactApplyResult( |
| | 1 | 72 | | status, |
| | 1 | 73 | | observedDigest, |
| | 1 | 74 | | RuntimeReference(recipe), |
| | 1 | 75 | | ToPlatformDiagnostics(runResult.Diagnostics, runResult.Error)); |
| | | 76 | | } |
| | | 77 | | |
| | 1 | 78 | | var reloadFailure = await ReloadShellsAsync(reloadTracker, cancellationToken); |
| | 1 | 79 | | if (reloadFailure is not null) |
| | | 80 | | { |
| | 0 | 81 | | return new PlatformRecipeArtifactApplyResult( |
| | 0 | 82 | | PlatformArtifactStatus.Failed, |
| | 0 | 83 | | observedDigest, |
| | 0 | 84 | | RuntimeReference(recipe), |
| | 0 | 85 | | [reloadFailure]); |
| | | 86 | | } |
| | | 87 | | |
| | 1 | 88 | | var diagnostics = ToPlatformDiagnostics(runResult.Diagnostics, null); |
| | 1 | 89 | | if (diagnostics.Count == 0) |
| | 0 | 90 | | diagnostics = [PlatformDiagnosticSanitizer.Info("elsa-platform.recipe-applied", "Loom recipe artifact was ap |
| | | 91 | | |
| | 1 | 92 | | return new PlatformRecipeArtifactApplyResult( |
| | 1 | 93 | | PlatformArtifactStatus.Applied, |
| | 1 | 94 | | observedDigest, |
| | 1 | 95 | | RuntimeReference(recipe), |
| | 1 | 96 | | diagnostics); |
| | 2 | 97 | | } |
| | | 98 | | |
| | | 99 | | private async Task<PlatformDiagnostic?> ReloadShellsAsync( |
| | | 100 | | PlatformShellReloadTracker reloadTracker, |
| | | 101 | | CancellationToken cancellationToken) |
| | | 102 | | { |
| | 2 | 103 | | foreach (var shellId in reloadTracker.ShellIds) |
| | | 104 | | { |
| | 0 | 105 | | var result = await shellRegistry.ReloadAsync(shellId, cancellationToken); |
| | 0 | 106 | | if (result.Error is not null) |
| | | 107 | | { |
| | 0 | 108 | | return PlatformDiagnosticSanitizer.Error( |
| | 0 | 109 | | "elsa-platform.shell-reload-failed", |
| | 0 | 110 | | $"Shell '{shellId}' reload failed: {result.Error.Message}"); |
| | | 111 | | } |
| | 0 | 112 | | } |
| | | 113 | | |
| | 1 | 114 | | return null; |
| | 1 | 115 | | } |
| | | 116 | | |
| | | 117 | | private static async Task<IReadOnlyDictionary<string, string>> ReadTextEntriesAsync( |
| | | 118 | | Stream artifactZip, |
| | | 119 | | CancellationToken cancellationToken) |
| | | 120 | | { |
| | 2 | 121 | | artifactZip.Position = 0; |
| | 2 | 122 | | using var archive = new ZipArchive(artifactZip, ZipArchiveMode.Read, leaveOpen: true); |
| | 2 | 123 | | var entries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 10 | 124 | | foreach (var entry in archive.Entries.OrderBy(x => x.FullName, StringComparer.Ordinal)) |
| | | 125 | | { |
| | 2 | 126 | | if (string.IsNullOrWhiteSpace(entry.Name) || !entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnor |
| | | 127 | | continue; |
| | | 128 | | |
| | 2 | 129 | | await using var stream = entry.Open(); |
| | 2 | 130 | | using var reader = new StreamReader(stream); |
| | 2 | 131 | | entries[NormalizePath(entry.FullName)] = await reader.ReadToEndAsync(cancellationToken); |
| | 2 | 132 | | } |
| | | 133 | | |
| | 2 | 134 | | return entries; |
| | 2 | 135 | | } |
| | | 136 | | |
| | | 137 | | private static string? FindRecipeJson(IReadOnlyDictionary<string, string> textEntries) |
| | | 138 | | { |
| | 2 | 139 | | var recipePath = textEntries.Keys |
| | 2 | 140 | | .Where(x => x.StartsWith("payload/recipes/", StringComparison.OrdinalIgnoreCase)) |
| | 2 | 141 | | .Where(x => x.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) |
| | 2 | 142 | | .OrderBy(x => x, StringComparer.Ordinal) |
| | 2 | 143 | | .FirstOrDefault(); |
| | | 144 | | |
| | 2 | 145 | | if (recipePath is not null) |
| | 2 | 146 | | return textEntries[recipePath]; |
| | | 147 | | |
| | 0 | 148 | | foreach (var candidate in new[] { "recipe.json", "loom.recipe.json" }) |
| | | 149 | | { |
| | 0 | 150 | | if (textEntries.TryGetValue(candidate, out var recipeJson)) |
| | 0 | 151 | | return recipeJson; |
| | | 152 | | } |
| | | 153 | | |
| | 0 | 154 | | return null; |
| | | 155 | | } |
| | | 156 | | |
| | | 157 | | private static async Task<PlatformArtifactDigest> ComputeDigestAsync( |
| | | 158 | | Stream stream, |
| | | 159 | | CancellationToken cancellationToken) |
| | | 160 | | { |
| | 2 | 161 | | stream.Position = 0; |
| | 2 | 162 | | using var sha = SHA256.Create(); |
| | 2 | 163 | | var hash = await sha.ComputeHashAsync(stream, cancellationToken); |
| | 2 | 164 | | stream.Position = 0; |
| | 2 | 165 | | return new PlatformArtifactDigest("sha256", Convert.ToHexString(hash).ToLowerInvariant()); |
| | 2 | 166 | | } |
| | | 167 | | |
| | | 168 | | private static IReadOnlyList<PlatformDiagnostic> ToPlatformDiagnostics( |
| | | 169 | | IReadOnlyList<RecipeDiagnostic> diagnostics, |
| | | 170 | | string? fallbackError) |
| | | 171 | | { |
| | 2 | 172 | | var platformDiagnostics = diagnostics.Select(ToPlatformDiagnostic).ToList(); |
| | 2 | 173 | | if (platformDiagnostics.Count == 0 && !string.IsNullOrWhiteSpace(fallbackError)) |
| | 0 | 174 | | platformDiagnostics.Add(PlatformDiagnosticSanitizer.Error("elsa-platform.recipe-failed", fallbackError)); |
| | | 175 | | |
| | 2 | 176 | | return platformDiagnostics; |
| | | 177 | | } |
| | | 178 | | |
| | | 179 | | private static PlatformDiagnostic ToPlatformDiagnostic(RecipeDiagnostic diagnostic) |
| | | 180 | | { |
| | 2 | 181 | | var message = diagnostic.ExceptionSummary is null |
| | 2 | 182 | | ? diagnostic.Message |
| | 2 | 183 | | : $"{diagnostic.Message} {diagnostic.ExceptionSummary}"; |
| | 2 | 184 | | return diagnostic.Severity switch |
| | 2 | 185 | | { |
| | 1 | 186 | | DiagnosticSeverity.Information => PlatformDiagnosticSanitizer.Info(diagnostic.Code, message), |
| | 0 | 187 | | DiagnosticSeverity.Warning => PlatformDiagnosticSanitizer.Warning(diagnostic.Code, message), |
| | 1 | 188 | | _ => PlatformDiagnosticSanitizer.Error(diagnostic.Code, message) |
| | 2 | 189 | | }; |
| | | 190 | | } |
| | | 191 | | |
| | | 192 | | private static bool DigestEquals(PlatformArtifactDigest left, PlatformArtifactDigest right) => |
| | 2 | 193 | | left.Algorithm.Equals(right.Algorithm, StringComparison.OrdinalIgnoreCase) |
| | 2 | 194 | | && left.Value.Equals(right.Value, StringComparison.OrdinalIgnoreCase); |
| | | 195 | | |
| | | 196 | | private static PlatformRecipeArtifactApplyResult Rejected( |
| | | 197 | | PlatformArtifactDigest observedDigest, |
| | | 198 | | string code, |
| | | 199 | | string message) => |
| | 0 | 200 | | new(PlatformArtifactStatus.Rejected, observedDigest, null, [PlatformDiagnosticSanitizer.Error(code, message)]); |
| | | 201 | | |
| | | 202 | | private static string RuntimeReference(Recipe recipe) => |
| | 2 | 203 | | recipe.Version is null |
| | 2 | 204 | | ? $"elsa://loom-recipes/{Uri.EscapeDataString(recipe.Name)}" |
| | 2 | 205 | | : $"elsa://loom-recipes/{Uri.EscapeDataString(recipe.Name)}@{Uri.EscapeDataString(recipe.Version)}"; |
| | | 206 | | |
| | | 207 | | private static string NormalizePath(string path) => |
| | 2 | 208 | | path.Replace('\\', '/').TrimStart('/'); |
| | | 209 | | } |