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

@@ -24,6 +24,7 @@ internal static class CommandFactory
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
@@ -220,10 +221,191 @@ internal static class CommandFactory
db.Add(fetch);
db.Add(merge);
db.Add(export);
db.Add(export);
return db;
}
private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows.");
var init = new Command("init", "Initialize Excititor ingest state.");
var initProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to initialize.",
Arity = ArgumentArity.ZeroOrMore
};
var resumeOption = new Option<bool>("--resume")
{
Description = "Resume ingest from the last persisted checkpoint instead of starting fresh."
};
init.Add(initProviders);
init.Add(resumeOption);
init.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>();
var resume = parseResult.GetValue(resumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken);
});
var pull = new Command("pull", "Trigger Excititor ingest for configured providers.");
var pullProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to ingest.",
Arity = ArgumentArity.ZeroOrMore
};
var sinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to begin the ingest window."
};
var windowOption = new Option<TimeSpan?>("--window")
{
Description = "Optional window duration (e.g. 24:00:00)."
};
var forceOption = new Option<bool>("--force")
{
Description = "Force ingestion even if the backend reports no pending work."
};
pull.Add(pullProviders);
pull.Add(sinceOption);
pull.Add(windowOption);
pull.Add(forceOption);
pull.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
var since = parseResult.GetValue(sinceOption);
var window = parseResult.GetValue(windowOption);
var force = parseResult.GetValue(forceOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
});
var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token.");
var resumeProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to resume.",
Arity = ArgumentArity.ZeroOrMore
};
var checkpointOption = new Option<string?>("--checkpoint")
{
Description = "Optional checkpoint identifier to resume from."
};
resume.Add(resumeProviders);
resume.Add(checkpointOption);
resume.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>();
var checkpoint = parseResult.GetValue(checkpointOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken);
});
var list = new Command("list-providers", "List Excititor providers and their ingest status.");
var includeDisabledOption = new Option<bool>("--include-disabled")
{
Description = "Include disabled providers in the listing."
};
list.Add(includeDisabledOption);
list.SetAction((parseResult, _) =>
{
var includeDisabled = parseResult.GetValue(includeDisabledOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken);
});
var export = new Command("export", "Trigger Excititor export generation.");
var formatOption = new Option<string>("--format")
{
Description = "Export format (e.g. openvex, json)."
};
var exportDeltaOption = new Option<bool>("--delta")
{
Description = "Request a delta export when supported."
};
var exportScopeOption = new Option<string?>("--scope")
{
Description = "Optional policy scope or tenant identifier."
};
var exportSinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to restrict export contents."
};
var exportProviderOption = new Option<string?>("--provider")
{
Description = "Optional provider identifier when requesting targeted exports."
};
export.Add(formatOption);
export.Add(exportDeltaOption);
export.Add(exportScopeOption);
export.Add(exportSinceOption);
export.Add(exportProviderOption);
export.SetAction((parseResult, _) =>
{
var format = parseResult.GetValue(formatOption) ?? "openvex";
var delta = parseResult.GetValue(exportDeltaOption);
var scope = parseResult.GetValue(exportScopeOption);
var since = parseResult.GetValue(exportSinceOption);
var provider = parseResult.GetValue(exportProviderOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, verbose, cancellationToken);
});
var verify = new Command("verify", "Verify Excititor exports or attestations.");
var exportIdOption = new Option<string?>("--export-id")
{
Description = "Export identifier to verify."
};
var digestOption = new Option<string?>("--digest")
{
Description = "Expected digest for the export or attestation."
};
var attestationOption = new Option<string?>("--attestation")
{
Description = "Path to a local attestation file to verify (base64 content will be uploaded)."
};
verify.Add(exportIdOption);
verify.Add(digestOption);
verify.Add(attestationOption);
verify.SetAction((parseResult, _) =>
{
var exportId = parseResult.GetValue(exportIdOption);
var digest = parseResult.GetValue(digestOption);
var attestation = parseResult.GetValue(attestationOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken);
});
var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories.");
var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to reconcile.",
Arity = ArgumentArity.ZeroOrMore
};
var maxAgeOption = new Option<TimeSpan?>("--max-age")
{
Description = "Optional maximum age window (e.g. 7.00:00:00)."
};
reconcile.Add(reconcileProviders);
reconcile.Add(maxAgeOption);
reconcile.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
var maxAge = parseResult.GetValue(maxAgeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
});
excititor.Add(init);
excititor.Add(pull);
excititor.Add(resume);
excititor.Add(list);
excititor.Add(export);
excititor.Add(verify);
excititor.Add(reconcile);
return excititor;
}
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");

View File

@@ -4,6 +4,8 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text;
@@ -340,6 +342,310 @@ internal static class CommandHandlers
}
}
public static Task HandleExcititorInitAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
bool resume,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (resume)
{
payload["resume"] = true;
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor init",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["resume"] = resume
},
client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorPullAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
DateTimeOffset? since,
TimeSpan? window,
bool force,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (since.HasValue)
{
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
if (window.HasValue)
{
payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture);
}
if (force)
{
payload["force"] = true;
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor pull",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["force"] = force,
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["window"] = window?.ToString("c", CultureInfo.InvariantCulture)
},
client => client.ExecuteExcititorOperationAsync("ingest/run", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorResumeAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
string? checkpoint,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (!string.IsNullOrWhiteSpace(checkpoint))
{
payload["checkpoint"] = checkpoint.Trim();
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor resume",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["checkpoint"] = checkpoint
},
client => client.ExecuteExcititorOperationAsync("ingest/resume", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static async Task HandleExcititorListProvidersAsync(
IServiceProvider services,
bool includeDisabled,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-list-providers");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.list-providers", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "excititor list-providers");
activity?.SetTag("stellaops.cli.include_disabled", includeDisabled);
using var duration = CliMetrics.MeasureCommandDuration("excititor list-providers");
try
{
var providers = await client.GetExcititorProvidersAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
Environment.ExitCode = 0;
logger.LogInformation("Providers returned: {Count}", providers.Count);
if (providers.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested");
foreach (var provider in providers)
{
table.AddRow(
provider.Id,
provider.Kind,
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
provider.Enabled ? "yes" : "no",
provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown");
}
AnsiConsole.Write(table);
}
else
{
foreach (var provider in providers)
{
logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}",
provider.Id,
provider.Kind,
provider.Enabled ? "yes" : "no",
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown");
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list Excititor providers.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static Task HandleExcititorExportAsync(
IServiceProvider services,
string format,
bool delta,
string? scope,
DateTimeOffset? since,
string? provider,
bool verbose,
CancellationToken cancellationToken)
{
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
["delta"] = delta
};
if (!string.IsNullOrWhiteSpace(scope))
{
payload["scope"] = scope.Trim();
}
if (since.HasValue)
{
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(provider))
{
payload["provider"] = provider.Trim();
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor export",
verbose,
new Dictionary<string, object?>
{
["format"] = payload["format"],
["delta"] = delta,
["scope"] = scope,
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["provider"] = provider
},
client => client.ExecuteExcititorOperationAsync("export", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorVerifyAsync(
IServiceProvider services,
string? exportId,
string? digest,
string? attestationPath,
bool verbose,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exportId) && string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(attestationPath))
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify");
logger.LogError("At least one of --export-id, --digest, or --attestation must be provided.");
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(exportId))
{
payload["exportId"] = exportId.Trim();
}
if (!string.IsNullOrWhiteSpace(digest))
{
payload["digest"] = digest.Trim();
}
if (!string.IsNullOrWhiteSpace(attestationPath))
{
var fullPath = Path.GetFullPath(attestationPath);
if (!File.Exists(fullPath))
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify");
logger.LogError("Attestation file not found at {Path}.", fullPath);
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var bytes = File.ReadAllBytes(fullPath);
payload["attestation"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["fileName"] = Path.GetFileName(fullPath),
["base64"] = Convert.ToBase64String(bytes)
};
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor verify",
verbose,
new Dictionary<string, object?>
{
["export_id"] = exportId,
["digest"] = digest,
["attestation_path"] = attestationPath
},
client => client.ExecuteExcititorOperationAsync("verify", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorReconcileAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
TimeSpan? maxAge,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (maxAge.HasValue)
{
payload["maxAge"] = maxAge.Value.ToString("c", CultureInfo.InvariantCulture);
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor reconcile",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["max_age"] = maxAge?.ToString("c", CultureInfo.InvariantCulture)
},
client => client.ExecuteExcititorOperationAsync("reconcile", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static async Task HandleAuthLoginAsync(
IServiceProvider services,
StellaOpsCliOptions options,
@@ -1111,12 +1417,109 @@ internal static class CommandHandlers
"jti"
};
private static async Task ExecuteExcititorCommandAsync(
IServiceProvider services,
string commandName,
bool verbose,
IDictionary<string, object?>? activityTags,
Func<IBackendOperationsClient, Task<ExcititorOperationResult>> operation,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(commandName.Replace(' ', '-'));
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}" , ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", commandName);
if (activityTags is not null)
{
foreach (var tag in activityTags)
{
activity?.SetTag(tag.Key, tag.Value);
}
}
using var duration = CliMetrics.MeasureCommandDuration(commandName);
try
{
var result = await operation(client).ConfigureAwait(false);
if (result.Success)
{
if (!string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation(result.Message);
}
else
{
logger.LogInformation("Operation completed successfully.");
}
if (!string.IsNullOrWhiteSpace(result.Location))
{
logger.LogInformation("Location: {Location}", result.Location);
}
if (result.Payload is JsonElement payload && payload.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
logger.LogDebug("Response payload: {Payload}", payload.ToString());
}
Environment.ExitCode = 0;
}
else
{
logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message);
Environment.ExitCode = 1;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Excititor operation failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
{
if (providers is null || providers.Count == 0)
{
return Array.Empty<string>();
}
var list = new List<string>();
foreach (var provider in providers)
{
if (!string.IsNullOrWhiteSpace(provider))
{
list.Add(provider.Trim());
}
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
{
foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList())
{
source.Remove(key);
}
return source;
}
private static async Task TriggerJobAsync(
IBackendOperationsClient client,
ILogger logger,
string jobKind,
IDictionary<string, object?> parameters,
CancellationToken cancellationToken)
IDictionary<string, object?> parameters,
CancellationToken cancellationToken)
{
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
if (result.Success)

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);

View File

@@ -14,6 +14,9 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|Expose auth client resilience settings|DevEx/CLI|Auth libraries LIB5|**DONE (2025-10-10)** CLI options now bind resilience knobs, `AddStellaOpsAuthClient` honours them, and tests cover env overrides.|
|Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.|
|Surface password policy diagnostics in CLI output|DevEx/CLI, Security Guild|AUTHSEC-CRYPTO-02-004|**DONE (2025-10-15)** CLI startup runs the Authority plug-in analyzer, logs weakened password policy warnings with manifest paths, added unit tests (`dotnet test src/StellaOps.Cli.Tests`) and updated docs/09 with remediation guidance.|
|EXCITITOR-CLI-01-001 Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|TODO Introduce `excititor` verb hierarchy (init/pull/resume/list-providers/export/verify/reconcile) forwarding to WebService with token auth and consistent exit codes.|
|EXCITITOR-CLI-01-001 Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|DONE (2025-10-18) Introduced `excititor` verbs (init/pull/resume/list-providers/export/verify/reconcile) with token-auth backend calls, provenance-friendly logging, and regression coverage.|
|EXCITITOR-CLI-01-002 Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|TODO Display export metadata (sha256, size, Rekor link), support optional artifact download path, and handle cache hits gracefully.|
|EXCITITOR-CLI-01-003 CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|TODO Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|CLI-RUNTIME-13-005 Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|TODO Add `runtime policy test` and related verbs to query `/policy/runtime`, display verdicts/TTL/reasons, and support batch inputs.|
|CLI-OFFLINE-13-006 Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.|
|CLI-PLUGIN-13-007 Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|