Files
scadalink-design/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs

203 lines
8.3 KiB
C#

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).
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set.
/// </summary>
public sealed class AuditQueryArgs
{
public string? Since { get; set; }
public string? Until { get; set; }
public string[] Channel { get; set; } = Array.Empty<string>();
public string[] Kind { get; set; } = Array.Empty<string>();
public string[] Status { get; set; } = Array.Empty<string>();
public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; }
public string? Actor { get; set; }
public string? CorrelationId { get; set; }
public string? ExecutionId { 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. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </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)}");
}
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, 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));
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
// --errors-only is a convenience shorthand for the Failed status filter. The
// server's status filter is multi-value, but --errors-only stays a single-status
// override: it pins status=Failed and supersedes any explicit --status values.
if (args.ErrorsOnly)
{
Add("status", "Failed");
}
else
{
AddEach("status", args.Status);
}
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
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;
}
}
}