FUll implementation plan (first draft)

This commit is contained in:
master
2025-10-19 00:28:48 +03:00
parent 052da7a7d0
commit 8dc7273e27
125 changed files with 5438 additions and 166 deletions

View File

@@ -231,9 +231,99 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return new JobTriggerResult(true, "Accepted", location, run);
}
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
return new JobTriggerResult(false, failureMessage, null, null);
}
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
return new JobTriggerResult(false, failureMessage, null, null);
}
public async Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(route))
{
throw new ArgumentException("Route must be provided.", nameof(route));
}
var relative = route.TrimStart('/');
using var request = CreateRequest(method, $"excititor/{relative}");
if (payload is not null && method != HttpMethod.Get && method != HttpMethod.Delete)
{
request.Content = JsonContent.Create(payload, options: SerializerOptions);
}
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
var (message, payloadElement) = await ExtractExcititorResponseAsync(response, cancellationToken).ConfigureAwait(false);
var location = response.Headers.Location?.ToString();
return new ExcititorOperationResult(true, message, location, payloadElement);
}
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
return new ExcititorOperationResult(false, failure, null, null);
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
if (response.Content is null || response.Content.Headers.ContentLength is 0)
{
return Array.Empty<ExcititorProviderSummary>();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
if (stream is null || stream.Length == 0)
{
return Array.Empty<ExcititorProviderSummary>();
}
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("providers", out var providersProperty))
{
root = providersProperty;
}
if (root.ValueKind != JsonValueKind.Array)
{
return Array.Empty<ExcititorProviderSummary>();
}
var list = new List<ExcititorProviderSummary>();
foreach (var item in root.EnumerateArray())
{
var id = GetStringProperty(item, "id") ?? string.Empty;
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
var kind = GetStringProperty(item, "kind") ?? "unknown";
var displayName = GetStringProperty(item, "displayName") ?? id;
var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty;
var enabled = GetBooleanProperty(item, "enabled", defaultValue: true);
var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt");
list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested));
}
return list;
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
{
@@ -328,10 +418,114 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
}
private async Task<(string Message, JsonElement? Payload)> ExtractExcititorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content is null || response.Content.Headers.ContentLength is 0)
{
return ($"HTTP {(int)response.StatusCode}", null);
}
try
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
if (stream is null || stream.Length == 0)
{
return ($"HTTP {(int)response.StatusCode}", null);
}
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement.Clone();
string? message = null;
if (root.ValueKind == JsonValueKind.Object)
{
message = GetStringProperty(root, "message") ?? GetStringProperty(root, "status");
}
if (string.IsNullOrWhiteSpace(message))
{
message = root.ValueKind == JsonValueKind.Object || root.ValueKind == JsonValueKind.Array
? root.ToString()
: root.GetRawText();
}
return (message ?? $"HTTP {(int)response.StatusCode}", root);
}
catch (JsonException)
{
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return (string.IsNullOrWhiteSpace(text) ? $"HTTP {(int)response.StatusCode}" : text.Trim(), null);
}
}
private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property))
{
return true;
}
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var candidate in element.EnumerateObject())
{
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
property = candidate.Value;
return true;
}
}
}
property = default;
return false;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
if (property.ValueKind == JsonValueKind.String)
{
return property.GetString();
}
}
return null;
}
private static bool GetBooleanProperty(JsonElement element, string propertyName, bool defaultValue)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
_ => defaultValue
};
}
return defaultValue;
}
private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String)
{
if (DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
return parsed.ToUniversalTime();
}
}
return null;
}
private void EnsureBackendConfigured()
{
if (_httpClient.BaseAddress is null)
{
{
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
}

View File

@@ -1,16 +1,21 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IBackendOperationsClient
{
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
}
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using System.Text.Json;
namespace StellaOps.Cli.Services.Models;
internal sealed record ExcititorOperationResult(
bool Success,
string Message,
string? Location,
JsonElement? Payload);

View File

@@ -0,0 +1,11 @@
using System;
namespace StellaOps.Cli.Services.Models;
internal sealed record ExcititorProviderSummary(
string Id,
string Kind,
string DisplayName,
string TrustTier,
bool Enabled,
DateTimeOffset? LastIngestedAt);