feat(cli): scadalink audit query subcommand (#23 M8)
This commit is contained in:
181
src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
Normal file
181
src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
|
||||
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
|
||||
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
|
||||
/// </summary>
|
||||
public sealed class AuditQueryArgs
|
||||
{
|
||||
public string? Since { get; set; }
|
||||
public string? Until { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Site { get; set; }
|
||||
public string? Instance { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
public bool ErrorsOnly { get; set; }
|
||||
public int PageSize { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>audit query</c> subcommand: time-spec resolution, query-string
|
||||
/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring
|
||||
/// so each piece is unit-testable without standing up the command tree.
|
||||
/// </summary>
|
||||
public static class AuditQueryHelpers
|
||||
{
|
||||
// <number><unit> where unit is s/m/h/d — a relative offset back from "now".
|
||||
private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a time-spec to an absolute <see cref="DateTimeOffset"/>. Accepts a
|
||||
/// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
|
||||
/// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
|
||||
/// </summary>
|
||||
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
|
||||
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(spec))
|
||||
throw new FormatException("Empty time value.");
|
||||
|
||||
var match = RelativeSpec.Match(spec.Trim());
|
||||
if (match.Success)
|
||||
{
|
||||
var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
var offset = match.Groups[2].Value switch
|
||||
{
|
||||
"s" => TimeSpan.FromSeconds(amount),
|
||||
"m" => TimeSpan.FromMinutes(amount),
|
||||
"h" => TimeSpan.FromHours(amount),
|
||||
"d" => TimeSpan.FromDays(amount),
|
||||
_ => throw new FormatException($"Unknown time unit in '{spec}'."),
|
||||
};
|
||||
return now - offset;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute))
|
||||
{
|
||||
return absolute;
|
||||
}
|
||||
|
||||
throw new FormatException(
|
||||
$"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
|
||||
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c>
|
||||
/// maps to <c>status=Failed</c> (the server takes a single status value).
|
||||
/// </summary>
|
||||
public static string BuildQueryString(
|
||||
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
void Add(string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(args.Since))
|
||||
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
|
||||
if (!string.IsNullOrWhiteSpace(args.Until))
|
||||
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
|
||||
|
||||
Add("channel", args.Channel);
|
||||
Add("kind", args.Kind);
|
||||
|
||||
// --errors-only is a convenience shorthand for the single-value Failed status
|
||||
// filter. The server's status filter accepts one value, so --errors-only and an
|
||||
// explicit --status are mutually exclusive in effect; --errors-only wins.
|
||||
Add("status", args.ErrorsOnly ? "Failed" : args.Status);
|
||||
|
||||
Add("sourceSiteId", args.Site);
|
||||
Add("instance", args.Instance);
|
||||
Add("target", args.Target);
|
||||
Add("actor", args.Actor);
|
||||
Add("correlationId", args.CorrelationId);
|
||||
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (afterOccurredAtUtc.HasValue)
|
||||
Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture));
|
||||
Add("afterEventId", afterEventId);
|
||||
|
||||
return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the query: GETs <c>/api/audit/query</c>, renders each page with
|
||||
/// <paramref name="formatter"/>, and — when <paramref name="fetchAll"/> is set —
|
||||
/// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
|
||||
/// process exit code (0 success, non-zero on HTTP/transport error).
|
||||
/// </summary>
|
||||
public static async Task<int> RunQueryAsync(
|
||||
ManagementHttpClient client,
|
||||
AuditQueryArgs args,
|
||||
bool fetchAll,
|
||||
IAuditFormatter formatter,
|
||||
TextWriter output,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
DateTimeOffset? afterOccurredAtUtc = null;
|
||||
string? afterEventId = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId);
|
||||
var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30));
|
||||
|
||||
if (response.JsonData == null)
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array
|
||||
? evts.EnumerateArray().ToList()
|
||||
: new List<JsonElement>();
|
||||
formatter.WritePage(events, output);
|
||||
output.Flush();
|
||||
|
||||
if (!fetchAll)
|
||||
return 0;
|
||||
|
||||
if (!root.TryGetProperty("nextCursor", out var cursor)
|
||||
|| cursor.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1)
|
||||
&& c1.ValueKind == JsonValueKind.String
|
||||
? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
|
||||
: null;
|
||||
afterEventId = cursor.TryGetProperty("afterEventId", out var c2)
|
||||
&& c2.ValueKind == JsonValueKind.String
|
||||
? c2.GetString()
|
||||
: null;
|
||||
|
||||
// A malformed cursor (object present but missing both keys) would loop
|
||||
// forever — treat it as the end of results.
|
||||
if (afterOccurredAtUtc == null && afterEventId == null)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user