| | | 1 | | using System.Net; |
| | | 2 | | using System.Net.Http.Json; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using System.Text.Json.Serialization; |
| | | 5 | | using Elsa.Platform.Integration.Models; |
| | | 6 | | using Elsa.Platform.Integration.Options; |
| | | 7 | | using Microsoft.Extensions.Options; |
| | | 8 | | |
| | | 9 | | namespace Elsa.Platform.Integration.Services; |
| | | 10 | | |
| | 0 | 11 | | public class PlatformRuntimeCommandClient(HttpClient httpClient, IOptions<ElsaPlatformIntegrationOptions> options) : IPl |
| | | 12 | | { |
| | | 13 | | private const string EngineSecretHeaderName = "X-Elsa-Engine-Secret"; |
| | | 14 | | private const string LeaseHeaderName = "X-Elsa-Command-Lease"; |
| | | 15 | | private const string WorkerHeaderName = "X-Elsa-Worker-Id"; |
| | 0 | 16 | | private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); |
| | 0 | 17 | | private readonly ElsaPlatformIntegrationOptions _options = options.Value; |
| | | 18 | | |
| | | 19 | | public async Task<IReadOnlyList<PlatformRuntimeCommand>> PollAsync(CancellationToken cancellationToken = default) |
| | | 20 | | { |
| | 0 | 21 | | using var request = CreateRequest(HttpMethod.Get, BuildUri($"/deployments/runtime/engines/{_options.EngineId:D}/ |
| | 0 | 22 | | using var response = await httpClient.SendAsync(request, cancellationToken); |
| | 0 | 23 | | if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) |
| | 0 | 24 | | throw new InvalidOperationException("Elsa Platform runtime command poll was not authorized."); |
| | | 25 | | |
| | 0 | 26 | | response.EnsureSuccessStatusCode(); |
| | 0 | 27 | | var body = await response.Content.ReadFromJsonAsync<PlatformRuntimeCommandListResponse>(JsonOptions, cancellatio |
| | 0 | 28 | | return body?.Commands ?? []; |
| | 0 | 29 | | } |
| | | 30 | | |
| | | 31 | | public async Task<PlatformRuntimeCommandClaimResponse?> ClaimAsync(Guid commandId, CancellationToken cancellationTok |
| | | 32 | | { |
| | 0 | 33 | | using var response = await SendJsonAsync( |
| | 0 | 34 | | BuildUri($"/deployments/runtime/commands/{commandId:D}/claim"), |
| | 0 | 35 | | new PlatformRuntimeCommandClaimRequest(_options.EngineId, _options.WorkerId, (int)_options.ClaimLeaseDuratio |
| | 0 | 36 | | cancellationToken); |
| | | 37 | | |
| | 0 | 38 | | if (response.StatusCode == HttpStatusCode.Conflict || response.StatusCode == HttpStatusCode.NotFound) |
| | 0 | 39 | | return null; |
| | | 40 | | |
| | 0 | 41 | | response.EnsureSuccessStatusCode(); |
| | 0 | 42 | | return await response.Content.ReadFromJsonAsync<PlatformRuntimeCommandClaimResponse>(JsonOptions, cancellationTo |
| | 0 | 43 | | } |
| | | 44 | | |
| | | 45 | | public async Task<Stream> DownloadArtifactAsync( |
| | | 46 | | PlatformRuntimeCommand command, |
| | | 47 | | PlatformArtifactItem artifact, |
| | | 48 | | string leaseToken, |
| | | 49 | | CancellationToken cancellationToken = default) |
| | | 50 | | { |
| | 0 | 51 | | if (string.IsNullOrWhiteSpace(artifact.DownloadUrl)) |
| | 0 | 52 | | throw new InvalidOperationException("Platform runtime command artifact does not include a download URL."); |
| | | 53 | | |
| | 0 | 54 | | using var request = CreateRequest(HttpMethod.Get, BuildUri(artifact.DownloadUrl)); |
| | 0 | 55 | | request.Headers.Add(LeaseHeaderName, leaseToken); |
| | 0 | 56 | | request.Headers.Add(WorkerHeaderName, _options.WorkerId); |
| | 0 | 57 | | using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationT |
| | 0 | 58 | | response.EnsureSuccessStatusCode(); |
| | 0 | 59 | | var stream = await response.Content.ReadAsStreamAsync(cancellationToken); |
| | 0 | 60 | | var buffer = new MemoryStream(); |
| | 0 | 61 | | await CopyBoundedAsync(stream, buffer, cancellationToken); |
| | 0 | 62 | | buffer.Position = 0; |
| | 0 | 63 | | return buffer; |
| | 0 | 64 | | } |
| | | 65 | | |
| | | 66 | | public Task ReportProgressAsync( |
| | | 67 | | Guid commandId, |
| | | 68 | | string leaseToken, |
| | | 69 | | string status, |
| | | 70 | | int? percentComplete, |
| | | 71 | | string message, |
| | | 72 | | CancellationToken cancellationToken = default) => |
| | 0 | 73 | | SendMutationAsync( |
| | 0 | 74 | | commandId, |
| | 0 | 75 | | "progress", |
| | 0 | 76 | | new PlatformRuntimeCommandProgressRequest(leaseToken, status, percentComplete, message), |
| | 0 | 77 | | cancellationToken); |
| | | 78 | | |
| | | 79 | | public Task CompleteAsync(Guid commandId, PlatformRuntimeCommandCompleteRequest request, CancellationToken cancellat |
| | 0 | 80 | | SendMutationAsync(commandId, "complete", request, cancellationToken); |
| | | 81 | | |
| | | 82 | | public Task FailAsync(Guid commandId, PlatformRuntimeCommandFailRequest request, CancellationToken cancellationToken |
| | 0 | 83 | | SendMutationAsync(commandId, "fail", request, cancellationToken); |
| | | 84 | | |
| | | 85 | | public Task RejectAsync(Guid commandId, PlatformRuntimeCommandRejectRequest request, CancellationToken cancellationT |
| | 0 | 86 | | SendMutationAsync(commandId, "reject", request, cancellationToken); |
| | | 87 | | |
| | | 88 | | private async Task SendMutationAsync<TRequest>( |
| | | 89 | | Guid commandId, |
| | | 90 | | string action, |
| | | 91 | | TRequest body, |
| | | 92 | | CancellationToken cancellationToken) |
| | | 93 | | { |
| | 0 | 94 | | using var response = await SendJsonAsync(BuildUri($"/deployments/runtime/commands/{commandId:D}/{action}"), body |
| | 0 | 95 | | response.EnsureSuccessStatusCode(); |
| | 0 | 96 | | } |
| | | 97 | | |
| | | 98 | | private HttpRequestMessage CreateRequest(HttpMethod method, Uri uri) |
| | | 99 | | { |
| | 0 | 100 | | var request = new HttpRequestMessage(method, uri); |
| | 0 | 101 | | if (!string.IsNullOrWhiteSpace(_options.EngineSecret)) |
| | 0 | 102 | | request.Headers.TryAddWithoutValidation(EngineSecretHeaderName, _options.EngineSecret); |
| | 0 | 103 | | return request; |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | private async Task<HttpResponseMessage> SendJsonAsync<TRequest>(Uri uri, TRequest body, CancellationToken cancellati |
| | | 107 | | { |
| | 0 | 108 | | var request = CreateRequest(HttpMethod.Post, uri); |
| | 0 | 109 | | request.Content = JsonContent.Create(body, options: JsonOptions); |
| | 0 | 110 | | return await httpClient.SendAsync(request, cancellationToken); |
| | 0 | 111 | | } |
| | | 112 | | |
| | | 113 | | private Uri BuildUri(string path) |
| | | 114 | | { |
| | 0 | 115 | | var endpoint = _options.PlatformEndpoint ?? throw new InvalidOperationException("Elsa Platform endpoint is requi |
| | 0 | 116 | | if (Uri.TryCreate(path, UriKind.Absolute, out var absoluteUri)) |
| | 0 | 117 | | return absoluteUri; |
| | | 118 | | |
| | 0 | 119 | | var relative = path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) |
| | 0 | 120 | | ? path |
| | 0 | 121 | | : $"/api/workspaces/{_options.WorkspaceId:D}{path}"; |
| | 0 | 122 | | return new Uri($"{endpoint.AbsoluteUri.TrimEnd('/')}{relative}"); |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | private async Task CopyBoundedAsync(Stream source, Stream destination, CancellationToken cancellationToken) |
| | | 126 | | { |
| | 0 | 127 | | var buffer = new byte[81920]; |
| | 0 | 128 | | long total = 0; |
| | 0 | 129 | | while (true) |
| | | 130 | | { |
| | 0 | 131 | | var read = await source.ReadAsync(buffer, cancellationToken); |
| | 0 | 132 | | if (read == 0) |
| | 0 | 133 | | return; |
| | | 134 | | |
| | 0 | 135 | | total += read; |
| | 0 | 136 | | if (total > _options.MaxArtifactBytes) |
| | 0 | 137 | | throw new InvalidOperationException("Elsa Platform artifact exceeds the configured runtime size limit.") |
| | | 138 | | |
| | 0 | 139 | | await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken); |
| | | 140 | | } |
| | 0 | 141 | | } |
| | | 142 | | |
| | | 143 | | private static JsonSerializerOptions CreateJsonOptions() |
| | | 144 | | { |
| | 0 | 145 | | var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); |
| | 0 | 146 | | options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); |
| | 0 | 147 | | return options; |
| | | 148 | | } |
| | | 149 | | } |