FUll implementation plan (first draft)
This commit is contained in:
@@ -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.");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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).|
|
||||
|
||||
Reference in New Issue
Block a user