up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -57,6 +57,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildVexCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
|
||||
@@ -8713,6 +8714,261 @@ internal static class CommandFactory
|
||||
return sbom;
|
||||
}
|
||||
|
||||
private static Command BuildExportCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var export = new Command("export", "Manage export profiles and runs.");
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit output in JSON."
|
||||
};
|
||||
|
||||
var profiles = new Command("profiles", "Manage export profiles.");
|
||||
|
||||
var profilesList = new Command("list", "List export profiles.");
|
||||
var profileLimitOption = new Option<int?>("--limit")
|
||||
{
|
||||
Description = "Maximum number of profiles to return."
|
||||
};
|
||||
var profileCursorOption = new Option<string?>("--cursor")
|
||||
{
|
||||
Description = "Pagination cursor."
|
||||
};
|
||||
profilesList.Add(profileLimitOption);
|
||||
profilesList.Add(profileCursorOption);
|
||||
profilesList.Add(jsonOption);
|
||||
profilesList.Add(verboseOption);
|
||||
profilesList.SetAction((parseResult, _) =>
|
||||
{
|
||||
var limit = parseResult.GetValue(profileLimitOption);
|
||||
var cursor = parseResult.GetValue(profileCursorOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportProfilesListAsync(
|
||||
services,
|
||||
limit,
|
||||
cursor,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var profilesShow = new Command("show", "Show export profile details.");
|
||||
var profileIdArg = new Argument<string>("profile-id")
|
||||
{
|
||||
Description = "Export profile identifier."
|
||||
};
|
||||
profilesShow.Add(profileIdArg);
|
||||
profilesShow.Add(jsonOption);
|
||||
profilesShow.Add(verboseOption);
|
||||
profilesShow.SetAction((parseResult, _) =>
|
||||
{
|
||||
var profileId = parseResult.GetValue(profileIdArg) ?? string.Empty;
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportProfileShowAsync(
|
||||
services,
|
||||
profileId,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
profiles.Add(profilesList);
|
||||
profiles.Add(profilesShow);
|
||||
export.Add(profiles);
|
||||
|
||||
var runs = new Command("runs", "Manage export runs.");
|
||||
|
||||
var runsList = new Command("list", "List export runs.");
|
||||
var runProfileOption = new Option<string?>("--profile-id")
|
||||
{
|
||||
Description = "Filter runs by profile ID."
|
||||
};
|
||||
var runLimitOption = new Option<int?>("--limit")
|
||||
{
|
||||
Description = "Maximum number of runs to return."
|
||||
};
|
||||
var runCursorOption = new Option<string?>("--cursor")
|
||||
{
|
||||
Description = "Pagination cursor."
|
||||
};
|
||||
runsList.Add(runProfileOption);
|
||||
runsList.Add(runLimitOption);
|
||||
runsList.Add(runCursorOption);
|
||||
runsList.Add(jsonOption);
|
||||
runsList.Add(verboseOption);
|
||||
runsList.SetAction((parseResult, _) =>
|
||||
{
|
||||
var profileId = parseResult.GetValue(runProfileOption);
|
||||
var limit = parseResult.GetValue(runLimitOption);
|
||||
var cursor = parseResult.GetValue(runCursorOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportRunsListAsync(
|
||||
services,
|
||||
profileId,
|
||||
limit,
|
||||
cursor,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var runIdArg = new Argument<string>("run-id")
|
||||
{
|
||||
Description = "Export run identifier."
|
||||
};
|
||||
var runsShow = new Command("show", "Show export run details.");
|
||||
runsShow.Add(runIdArg);
|
||||
runsShow.Add(jsonOption);
|
||||
runsShow.Add(verboseOption);
|
||||
runsShow.SetAction((parseResult, _) =>
|
||||
{
|
||||
var runId = parseResult.GetValue(runIdArg) ?? string.Empty;
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportRunShowAsync(
|
||||
services,
|
||||
runId,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var runsDownload = new Command("download", "Download an export bundle for a run.");
|
||||
runsDownload.Add(runIdArg);
|
||||
var runOutputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Path to write the export bundle.",
|
||||
IsRequired = true
|
||||
};
|
||||
var runOverwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Overwrite output file if it exists."
|
||||
};
|
||||
var runVerifyHashOption = new Option<string?>("--verify-hash")
|
||||
{
|
||||
Description = "Optional SHA256 hash to verify after download."
|
||||
};
|
||||
var runTypeOption = new Option<string>("--type")
|
||||
{
|
||||
Description = "Run type: evidence (default) or attestation."
|
||||
};
|
||||
runTypeOption.SetDefaultValue("evidence");
|
||||
|
||||
runsDownload.Add(runOutputOption);
|
||||
runsDownload.Add(runOverwriteOption);
|
||||
runsDownload.Add(runVerifyHashOption);
|
||||
runsDownload.Add(runTypeOption);
|
||||
runsDownload.Add(verboseOption);
|
||||
runsDownload.SetAction((parseResult, _) =>
|
||||
{
|
||||
var runId = parseResult.GetValue(runIdArg) ?? string.Empty;
|
||||
var output = parseResult.GetValue(runOutputOption) ?? string.Empty;
|
||||
var overwrite = parseResult.GetValue(runOverwriteOption);
|
||||
var verifyHash = parseResult.GetValue(runVerifyHashOption);
|
||||
var runType = parseResult.GetValue(runTypeOption) ?? "evidence";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportRunDownloadAsync(
|
||||
services,
|
||||
runId,
|
||||
output,
|
||||
overwrite,
|
||||
verifyHash,
|
||||
runType,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
runs.Add(runsList);
|
||||
runs.Add(runsShow);
|
||||
runs.Add(runsDownload);
|
||||
export.Add(runs);
|
||||
|
||||
var start = new Command("start", "Start export jobs.");
|
||||
var startProfileOption = new Option<string>("--profile-id")
|
||||
{
|
||||
Description = "Export profile identifier.",
|
||||
IsRequired = true
|
||||
};
|
||||
var startSelectorOption = new Option<string[]?>("--selector", new[] { "-s" })
|
||||
{
|
||||
Description = "Selector key=value filters (repeatable).",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
var startCallbackOption = new Option<string?>("--callback-url")
|
||||
{
|
||||
Description = "Optional callback URL for completion notifications."
|
||||
};
|
||||
|
||||
var startEvidence = new Command("evidence", "Start an evidence export run.");
|
||||
startEvidence.Add(startProfileOption);
|
||||
startEvidence.Add(startSelectorOption);
|
||||
startEvidence.Add(startCallbackOption);
|
||||
startEvidence.Add(jsonOption);
|
||||
startEvidence.Add(verboseOption);
|
||||
startEvidence.SetAction((parseResult, _) =>
|
||||
{
|
||||
var profileId = parseResult.GetValue(startProfileOption) ?? string.Empty;
|
||||
var selectors = parseResult.GetValue(startSelectorOption);
|
||||
var callback = parseResult.GetValue(startCallbackOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportStartEvidenceAsync(
|
||||
services,
|
||||
profileId,
|
||||
selectors,
|
||||
callback,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var startAttestation = new Command("attestation", "Start an attestation export run.");
|
||||
startAttestation.Add(startProfileOption);
|
||||
startAttestation.Add(startSelectorOption);
|
||||
var startTransparencyOption = new Option<bool>("--include-transparency")
|
||||
{
|
||||
Description = "Include transparency log entries."
|
||||
};
|
||||
startAttestation.Add(startTransparencyOption);
|
||||
startAttestation.Add(startCallbackOption);
|
||||
startAttestation.Add(jsonOption);
|
||||
startAttestation.Add(verboseOption);
|
||||
startAttestation.SetAction((parseResult, _) =>
|
||||
{
|
||||
var profileId = parseResult.GetValue(startProfileOption) ?? string.Empty;
|
||||
var selectors = parseResult.GetValue(startSelectorOption);
|
||||
var includeTransparency = parseResult.GetValue(startTransparencyOption);
|
||||
var callback = parseResult.GetValue(startCallbackOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleExportStartAttestationAsync(
|
||||
services,
|
||||
profileId,
|
||||
selectors,
|
||||
includeTransparency,
|
||||
callback,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
start.Add(startEvidence);
|
||||
start.Add(startAttestation);
|
||||
export.Add(start);
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
// CLI-PARITY-41-002: Notify command group
|
||||
private static Command BuildNotifyCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -9038,6 +9294,79 @@ internal static class CommandFactory
|
||||
|
||||
notify.Add(deliveries);
|
||||
|
||||
// notify simulate
|
||||
var simulate = new Command("simulate", "Simulate notification rules against events.");
|
||||
|
||||
var simulateEventsFileOption = new Option<string?>("--events-file")
|
||||
{
|
||||
Description = "Path to JSON file containing events array for simulation."
|
||||
};
|
||||
var simulateRulesFileOption = new Option<string?>("--rules-file")
|
||||
{
|
||||
Description = "Optional JSON file containing rules array to evaluate (overrides server rules)."
|
||||
};
|
||||
var simulateEnabledOnlyOption = new Option<bool>("--enabled-only")
|
||||
{
|
||||
Description = "Only evaluate enabled rules."
|
||||
};
|
||||
var simulateLookbackOption = new Option<int?>("--lookback-minutes")
|
||||
{
|
||||
Description = "Historical lookback window for events."
|
||||
};
|
||||
var simulateMaxEventsOption = new Option<int?>("--max-events")
|
||||
{
|
||||
Description = "Maximum events to evaluate."
|
||||
};
|
||||
var simulateEventKindOption = new Option<string?>("--event-kind")
|
||||
{
|
||||
Description = "Filter simulation to a specific event kind."
|
||||
};
|
||||
var simulateIncludeNonMatchesOption = new Option<bool>("--include-non-matches")
|
||||
{
|
||||
Description = "Include non-match explanations."
|
||||
};
|
||||
|
||||
simulate.Add(tenantOption);
|
||||
simulate.Add(simulateEventsFileOption);
|
||||
simulate.Add(simulateRulesFileOption);
|
||||
simulate.Add(simulateEnabledOnlyOption);
|
||||
simulate.Add(simulateLookbackOption);
|
||||
simulate.Add(simulateMaxEventsOption);
|
||||
simulate.Add(simulateEventKindOption);
|
||||
simulate.Add(simulateIncludeNonMatchesOption);
|
||||
simulate.Add(jsonOption);
|
||||
simulate.Add(verboseOption);
|
||||
|
||||
simulate.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var eventsFile = parseResult.GetValue(simulateEventsFileOption);
|
||||
var rulesFile = parseResult.GetValue(simulateRulesFileOption);
|
||||
var enabledOnly = parseResult.GetValue(simulateEnabledOnlyOption);
|
||||
var lookback = parseResult.GetValue(simulateLookbackOption);
|
||||
var maxEvents = parseResult.GetValue(simulateMaxEventsOption);
|
||||
var eventKind = parseResult.GetValue(simulateEventKindOption);
|
||||
var includeNonMatches = parseResult.GetValue(simulateIncludeNonMatchesOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleNotifySimulateAsync(
|
||||
services,
|
||||
tenant,
|
||||
eventsFile,
|
||||
rulesFile,
|
||||
enabledOnly,
|
||||
lookback,
|
||||
maxEvents,
|
||||
eventKind,
|
||||
includeNonMatches,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
notify.Add(simulate);
|
||||
|
||||
// notify send
|
||||
var send = new Command("send", "Send a notification.");
|
||||
|
||||
@@ -9112,6 +9441,61 @@ internal static class CommandFactory
|
||||
|
||||
notify.Add(send);
|
||||
|
||||
// notify ack
|
||||
var ack = new Command("ack", "Acknowledge a notification or incident.");
|
||||
var ackTenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant identifier (header)."
|
||||
};
|
||||
var ackIncidentOption = new Option<string?>("--incident-id")
|
||||
{
|
||||
Description = "Incident identifier to acknowledge."
|
||||
};
|
||||
var ackTokenOption = new Option<string?>("--token")
|
||||
{
|
||||
Description = "Signed acknowledgment token."
|
||||
};
|
||||
var ackByOption = new Option<string?>("--by")
|
||||
{
|
||||
Description = "Actor performing the acknowledgment."
|
||||
};
|
||||
var ackCommentOption = new Option<string?>("--comment")
|
||||
{
|
||||
Description = "Optional acknowledgment comment."
|
||||
};
|
||||
|
||||
ack.Add(ackTenantOption);
|
||||
ack.Add(ackIncidentOption);
|
||||
ack.Add(ackTokenOption);
|
||||
ack.Add(ackByOption);
|
||||
ack.Add(ackCommentOption);
|
||||
ack.Add(jsonOption);
|
||||
ack.Add(verboseOption);
|
||||
|
||||
ack.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(ackTenantOption);
|
||||
var incidentId = parseResult.GetValue(ackIncidentOption);
|
||||
var token = parseResult.GetValue(ackTokenOption);
|
||||
var by = parseResult.GetValue(ackByOption);
|
||||
var comment = parseResult.GetValue(ackCommentOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleNotifyAckAsync(
|
||||
services,
|
||||
tenant,
|
||||
incidentId,
|
||||
token,
|
||||
by,
|
||||
comment,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
notify.Add(ack);
|
||||
|
||||
return notify;
|
||||
}
|
||||
|
||||
@@ -10682,4 +11066,3 @@ internal static class CommandFactory
|
||||
return devportal;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ using Microsoft.Extensions.Options;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Prompts;
|
||||
@@ -24774,8 +24776,485 @@ stella policy test {policyName}.stella
|
||||
|
||||
#endregion
|
||||
|
||||
#region Export Handlers (CLI-EXPORT-35-037)
|
||||
|
||||
internal static async Task<int> HandleExportProfilesListAsync(
|
||||
IServiceProvider services,
|
||||
int? limit,
|
||||
string? cursor,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
var response = await client.ListProfilesAsync(cursor, limit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (response.Profiles.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No export profiles found.[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Profile ID");
|
||||
table.AddColumn("Name");
|
||||
table.AddColumn("Adapter");
|
||||
table.AddColumn("Format");
|
||||
table.AddColumn("Signing");
|
||||
table.AddColumn("Created");
|
||||
table.AddColumn("Updated");
|
||||
|
||||
foreach (var profile in response.Profiles)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(profile.ProfileId),
|
||||
Markup.Escape(profile.Name),
|
||||
Markup.Escape(profile.Adapter),
|
||||
Markup.Escape(profile.OutputFormat),
|
||||
profile.SigningEnabled ? "[green]Yes[/]" : "[grey]No[/]",
|
||||
profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture),
|
||||
profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleExportProfileShowAsync(
|
||||
IServiceProvider services,
|
||||
string profileId,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
var profile = await client.GetProfileAsync(profileId, cancellationToken).ConfigureAwait(false);
|
||||
if (profile is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile not found:[/] {Markup.Escape(profileId)}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(profile, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
var profileTable = new Table { Border = TableBorder.Rounded };
|
||||
profileTable.AddColumn("Field");
|
||||
profileTable.AddColumn("Value");
|
||||
profileTable.AddRow("Profile ID", Markup.Escape(profile.ProfileId));
|
||||
profileTable.AddRow("Name", Markup.Escape(profile.Name));
|
||||
profileTable.AddRow("Description", string.IsNullOrWhiteSpace(profile.Description) ? "[grey]-[/]" : Markup.Escape(profile.Description));
|
||||
profileTable.AddRow("Adapter", Markup.Escape(profile.Adapter));
|
||||
profileTable.AddRow("Format", Markup.Escape(profile.OutputFormat));
|
||||
profileTable.AddRow("Signing", profile.SigningEnabled ? "[green]Enabled[/]" : "[grey]Disabled[/]");
|
||||
profileTable.AddRow("Created", profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
|
||||
profileTable.AddRow("Updated", profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
||||
|
||||
if (profile.Selectors is { Count: > 0 })
|
||||
{
|
||||
var selectorTable = new Table { Title = new TableTitle("Selectors") };
|
||||
selectorTable.AddColumn("Key");
|
||||
selectorTable.AddColumn("Value");
|
||||
foreach (var selector in profile.Selectors)
|
||||
{
|
||||
selectorTable.AddRow(Markup.Escape(selector.Key), Markup.Escape(selector.Value));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(profileTable);
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.Write(selectorTable);
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.Write(profileTable);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleExportRunsListAsync(
|
||||
IServiceProvider services,
|
||||
string? profileId,
|
||||
int? limit,
|
||||
string? cursor,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
var response = await client.ListRunsAsync(profileId, cursor, limit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (response.Runs.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No export runs found.[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Run ID");
|
||||
table.AddColumn("Profile");
|
||||
table.AddColumn("Status");
|
||||
table.AddColumn("Progress");
|
||||
table.AddColumn("Started");
|
||||
table.AddColumn("Completed");
|
||||
table.AddColumn("Bundle");
|
||||
|
||||
foreach (var run in response.Runs)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(run.RunId),
|
||||
Markup.Escape(run.ProfileId),
|
||||
Markup.Escape(run.Status),
|
||||
run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]",
|
||||
run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
|
||||
run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
|
||||
string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
if (response.HasMore && !string.IsNullOrWhiteSpace(response.ContinuationToken))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]More available. Use --cursor {Markup.Escape(response.ContinuationToken)}[/]");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleExportRunShowAsync(
|
||||
IServiceProvider services,
|
||||
string runId,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
var run = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Run not found:[/] {Markup.Escape(runId)}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(run, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
var table = new Table { Border = TableBorder.Rounded };
|
||||
table.AddColumn("Field");
|
||||
table.AddColumn("Value");
|
||||
table.AddRow("Run ID", Markup.Escape(run.RunId));
|
||||
table.AddRow("Profile ID", Markup.Escape(run.ProfileId));
|
||||
table.AddRow("Status", Markup.Escape(run.Status));
|
||||
table.AddRow("Progress", run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]");
|
||||
table.AddRow("Started", run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
||||
table.AddRow("Completed", run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
||||
table.AddRow("Bundle Hash", string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
|
||||
table.AddRow("Bundle URL", string.IsNullOrWhiteSpace(run.BundleUrl) ? "[grey]-[/]" : Markup.Escape(run.BundleUrl));
|
||||
table.AddRow("Error Code", string.IsNullOrWhiteSpace(run.ErrorCode) ? "[grey]-[/]" : Markup.Escape(run.ErrorCode));
|
||||
table.AddRow("Error Message", string.IsNullOrWhiteSpace(run.ErrorMessage) ? "[grey]-[/]" : Markup.Escape(run.ErrorMessage));
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleExportRunDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string runId,
|
||||
string outputPath,
|
||||
bool overwrite,
|
||||
string? verifyHash,
|
||||
string runType,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
if (File.Exists(outputPath) && !overwrite)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Output file already exists:[/] {Markup.Escape(outputPath)} (use --overwrite to replace)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath)) ?? ".");
|
||||
|
||||
Stream? stream = null;
|
||||
if (string.Equals(runType, "attestation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
stream = await client.DownloadAttestationExportAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
stream = await client.DownloadEvidenceExportAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Export bundle not available for run:[/] {Markup.Escape(runId)}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
await using (stream)
|
||||
await using (var fileStream = File.Create(outputPath))
|
||||
{
|
||||
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verifyHash))
|
||||
{
|
||||
await using var file = File.OpenRead(outputPath);
|
||||
var hash = await SHA256.HashDataAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
var hashString = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
if (!string.Equals(hashString, verifyHash.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Hash verification failed.[/] expected={Markup.Escape(verifyHash)}, actual={hashString}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Bundle written to[/] {Markup.Escape(outputPath)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleExportStartEvidenceAsync(
|
||||
IServiceProvider services,
|
||||
string profileId,
|
||||
string[]? selectors,
|
||||
string? callbackUrl,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
var selectorMap = ParseSelectorMap(selectors);
|
||||
var request = new CreateEvidenceExportRequest(profileId, selectorMap, callbackUrl);
|
||||
var response = await client.CreateEvidenceExportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleExportStartAttestationAsync(
|
||||
IServiceProvider services,
|
||||
string profileId,
|
||||
string[]? selectors,
|
||||
bool includeTransparencyLog,
|
||||
string? callbackUrl,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<IExportCenterClient>();
|
||||
|
||||
var selectorMap = ParseSelectorMap(selectors);
|
||||
var request = new CreateAttestationExportRequest(profileId, selectorMap, includeTransparencyLog, callbackUrl);
|
||||
var response = await client.CreateAttestationExportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Attestation export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? ParseSelectorMap(string[]? selectors)
|
||||
{
|
||||
if (selectors is null || selectors.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var selector in selectors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = selector.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Ignoring selector with invalid format (expected key=value):[/] {Markup.Escape(selector)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
result[parts[0]] = parts[1];
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Notify Handlers (CLI-PARITY-41-002)
|
||||
|
||||
internal static async Task<int> HandleNotifySimulateAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string? eventsFile,
|
||||
string? rulesFile,
|
||||
bool enabledOnly,
|
||||
int? lookbackMinutes,
|
||||
int? maxEvents,
|
||||
string? eventKind,
|
||||
bool includeNonMatches,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<INotifyClient>();
|
||||
|
||||
var eventsPayload = LoadJsonElement(eventsFile);
|
||||
var rulesPayload = LoadJsonElement(rulesFile);
|
||||
|
||||
var request = new NotifySimulationRequest
|
||||
{
|
||||
TenantId = tenant,
|
||||
Events = eventsPayload,
|
||||
Rules = rulesPayload,
|
||||
EnabledRulesOnly = enabledOnly,
|
||||
HistoricalLookbackMinutes = lookbackMinutes,
|
||||
MaxEvents = maxEvents,
|
||||
EventKindFilter = eventKind,
|
||||
IncludeNonMatches = includeNonMatches
|
||||
};
|
||||
|
||||
var result = await client.SimulateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine(result.SimulationId is null
|
||||
? "[yellow]Simulation completed.[/]"
|
||||
: $"[green]Simulation {Markup.Escape(result.SimulationId)} completed.[/]");
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Total Events");
|
||||
table.AddColumn("Total Rules");
|
||||
table.AddColumn("Matched Events");
|
||||
table.AddColumn("Actions");
|
||||
table.AddColumn("Duration (ms)");
|
||||
|
||||
table.AddRow(
|
||||
(result.TotalEvents ?? 0).ToString(CultureInfo.InvariantCulture),
|
||||
(result.TotalRules ?? 0).ToString(CultureInfo.InvariantCulture),
|
||||
(result.MatchedEvents ?? 0).ToString(CultureInfo.InvariantCulture),
|
||||
(result.TotalActionsTriggered ?? 0).ToString(CultureInfo.InvariantCulture),
|
||||
result.DurationMs?.ToString("0.00", CultureInfo.InvariantCulture) ?? "-");
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleNotifyAckAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
string? incidentId,
|
||||
string? token,
|
||||
string? acknowledgedBy,
|
||||
string? comment,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SetVerbosity(services, verbose);
|
||||
var client = services.GetRequiredService<INotifyClient>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token) && string.IsNullOrWhiteSpace(incidentId))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Either --token or --incident-id is required.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var request = new NotifyAckRequest
|
||||
{
|
||||
TenantId = tenant,
|
||||
IncidentId = incidentId,
|
||||
Token = token,
|
||||
AcknowledgedBy = acknowledgedBy,
|
||||
Comment = comment
|
||||
};
|
||||
|
||||
var result = await client.AckAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Acknowledge failed:[/] {Markup.Escape(result.Error ?? "unknown error")}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Acknowledged.[/] incidentId={Markup.Escape(result.IncidentId ?? incidentId ?? "n/a")}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static JsonElement? LoadJsonElement(string? filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(filePath);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Failed to load JSON from {Markup.Escape(filePath)}:[/] {Markup.Escape(ex.Message)}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleNotifyChannelsListAsync(
|
||||
IServiceProvider services,
|
||||
string? tenant,
|
||||
|
||||
@@ -15,6 +15,7 @@ using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
@@ -124,6 +125,16 @@ internal static class Program
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "backend-api");
|
||||
|
||||
services.AddHttpClient<IExportCenterClient, ExportCenterClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var exportCenterUri))
|
||||
{
|
||||
client.BaseAddress = exportCenterUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "export-center-api");
|
||||
|
||||
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -67,4 +67,18 @@ internal interface INotifyClient
|
||||
Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Simulate rule evaluation.
|
||||
/// </summary>
|
||||
Task<NotifySimulationResult> SimulateAsync(
|
||||
NotifySimulationRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge an incident or signed token.
|
||||
/// </summary>
|
||||
Task<NotifyAckResult> AckAsync(
|
||||
NotifyAckRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
@@ -610,3 +611,83 @@ internal sealed class NotifySendResult
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class NotifySimulationRequest
|
||||
{
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("events")]
|
||||
public JsonElement? Events { get; init; }
|
||||
|
||||
[JsonPropertyName("rules")]
|
||||
public JsonElement? Rules { get; init; }
|
||||
|
||||
[JsonPropertyName("enabledRulesOnly")]
|
||||
public bool? EnabledRulesOnly { get; init; }
|
||||
|
||||
[JsonPropertyName("historicalLookbackMinutes")]
|
||||
public int? HistoricalLookbackMinutes { get; init; }
|
||||
|
||||
[JsonPropertyName("maxEvents")]
|
||||
public int? MaxEvents { get; init; }
|
||||
|
||||
[JsonPropertyName("eventKindFilter")]
|
||||
public string? EventKindFilter { get; init; }
|
||||
|
||||
[JsonPropertyName("includeNonMatches")]
|
||||
public bool? IncludeNonMatches { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class NotifySimulationResult
|
||||
{
|
||||
[JsonPropertyName("simulationId")]
|
||||
public string? SimulationId { get; init; }
|
||||
|
||||
[JsonPropertyName("totalEvents")]
|
||||
public int? TotalEvents { get; init; }
|
||||
|
||||
[JsonPropertyName("totalRules")]
|
||||
public int? TotalRules { get; init; }
|
||||
|
||||
[JsonPropertyName("matchedEvents")]
|
||||
public int? MatchedEvents { get; init; }
|
||||
|
||||
[JsonPropertyName("totalActionsTriggered")]
|
||||
public int? TotalActionsTriggered { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public double? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class NotifyAckRequest
|
||||
{
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("incidentId")]
|
||||
public string? IncidentId { get; init; }
|
||||
|
||||
[JsonPropertyName("acknowledgedBy")]
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
|
||||
public string? Token { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class NotifyAckResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("incidentId")]
|
||||
public string? IncidentId { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
@@ -569,6 +569,131 @@ internal sealed class NotifyClient : INotifyClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifySimulationResult> SimulateAsync(
|
||||
NotifySimulationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v2/simulate")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.simulate", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to simulate notify rules (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifySimulationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while simulating notify rules");
|
||||
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while simulating notify rules");
|
||||
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyAckResult> AckAsync(
|
||||
NotifyAckRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var hasToken = !string.IsNullOrWhiteSpace(request.Token);
|
||||
using var httpRequest = hasToken
|
||||
? new HttpRequestMessage(HttpMethod.Get, $"/api/v2/ack?token={Uri.EscapeDataString(request.Token!)}")
|
||||
: new HttpRequestMessage(HttpMethod.Post, "/api/v2/ack")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new AckApiRequestBody
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
IncidentId = request.IncidentId,
|
||||
AcknowledgedBy = request.AcknowledgedBy,
|
||||
Comment = request.Comment
|
||||
}, SerializerOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to acknowledge notification (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = payload };
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyAckResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyAckResult { Success = true, IncidentId = request.IncidentId };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while acknowledging notification");
|
||||
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = ex.Message };
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while acknowledging notification");
|
||||
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = "Request timed out" };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AckApiRequestBody
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public string? IncidentId { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
private static string BuildChannelListUri(NotifyChannelListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||
|
||||
Reference in New Issue
Block a user