feat(audit): M5.6 SourceNode sentinel backfill (purge-role) + CLI + runbook note (T5)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for an <c>audit backfill-source-node</c> invocation.
|
||||
/// </summary>
|
||||
public sealed class AuditBackfillSourceNodeArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Value written into <c>SourceNode</c> for NULL rows (default <c>"unknown"</c>).
|
||||
/// </summary>
|
||||
public string Sentinel { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Only rows with <c>OccurredAtUtc</c> strictly before this UTC datetime are
|
||||
/// eligible. Required — must be an ISO-8601 UTC datetime.
|
||||
/// </summary>
|
||||
public string Before { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum rows updated per batch (default 5000). Caps the per-transaction
|
||||
/// log footprint; the loop repeats until no rows remain.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 5000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>audit backfill-source-node</c> subcommand (Audit Log
|
||||
/// #23 M5.6 T5). Builds the request body, POSTs to
|
||||
/// <c>/api/audit/backfill-source-node</c>, and renders the result. Kept separate
|
||||
/// from the command wiring so each piece is unit-testable without standing up the
|
||||
/// command tree.
|
||||
/// </summary>
|
||||
public static class AuditBackfillHelpers
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonWriteOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the JSON request body for <c>POST /api/audit/backfill-source-node</c>.
|
||||
/// </summary>
|
||||
/// <param name="args">The backfill arguments.</param>
|
||||
/// <returns>A JSON string suitable for the request body.</returns>
|
||||
public static string BuildRequestBody(AuditBackfillSourceNodeArgs args)
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
sentinel = args.Sentinel,
|
||||
before = args.Before,
|
||||
batchSize = args.BatchSize,
|
||||
};
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the backfill: POSTs <c>/api/audit/backfill-source-node</c> and
|
||||
/// prints the result. Returns the process exit code (0 = success,
|
||||
/// 1 = error, 2 = authorization failure).
|
||||
/// </summary>
|
||||
/// <param name="client">The management HTTP client.</param>
|
||||
/// <param name="args">The backfill arguments.</param>
|
||||
/// <param name="output">The output writer for results.</param>
|
||||
/// <returns>A task that resolves to the process exit code.</returns>
|
||||
public static async Task<int> RunBackfillAsync(
|
||||
ManagementHttpClient client,
|
||||
AuditBackfillSourceNodeArgs args,
|
||||
TextWriter output)
|
||||
{
|
||||
var body = BuildRequestBody(args);
|
||||
var response = await client.SendPostAsync(
|
||||
"api/audit/backfill-source-node", body, TimeSpan.FromMinutes(10));
|
||||
|
||||
if (response.JsonData == null)
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
response.Error ?? "Backfill request failed.", response.ErrorCode ?? "ERROR");
|
||||
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
|
||||
}
|
||||
|
||||
// Parse and display the result.
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
var root = doc.RootElement;
|
||||
var rowsUpdated = root.TryGetProperty("rowsUpdated", out var r)
|
||||
? r.GetInt64()
|
||||
: 0L;
|
||||
var sentinel = root.TryGetProperty("sentinel", out var s)
|
||||
? s.GetString() ?? args.Sentinel
|
||||
: args.Sentinel;
|
||||
var before = root.TryGetProperty("before", out var b)
|
||||
? b.GetString() ?? args.Before
|
||||
: args.Before;
|
||||
|
||||
output.WriteLine($"SourceNode backfill complete.");
|
||||
output.WriteLine($" rows updated : {rowsUpdated}");
|
||||
output.WriteLine($" sentinel : {sentinel}");
|
||||
output.WriteLine($" before : {before}");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Server returned success but non-JSON body — not expected; print raw.
|
||||
output.WriteLine(response.JsonData);
|
||||
}
|
||||
|
||||
output.Flush();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public static class AuditCommands
|
||||
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildTree(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildBackfillSourceNode(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -288,4 +289,76 @@ public static class AuditCommands
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>audit backfill-source-node</c> sub-command (Audit Log #23 M5.6 T5).
|
||||
/// Sets <c>SourceNode</c> on historical pre-feature rows whose <c>SourceNode IS NULL</c>
|
||||
/// and <c>OccurredAtUtc</c> is older than <c>--before</c>, in batches. Admin-only.
|
||||
/// </summary>
|
||||
private static Command BuildBackfillSourceNode(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sentinelOption = new Option<string>("--sentinel")
|
||||
{
|
||||
Description = "Value to write for pre-feature rows whose node-of-origin is unknown (default: unknown)",
|
||||
};
|
||||
sentinelOption.DefaultValueFactory = _ => "unknown";
|
||||
|
||||
var beforeOption = new Option<string>("--before")
|
||||
{
|
||||
Description = "ISO-8601 UTC datetime; only rows older than this date are eligible (required)",
|
||||
Required = true,
|
||||
};
|
||||
|
||||
var batchOption = new Option<int>("--batch")
|
||||
{
|
||||
Description = "Max rows updated per batch (default: 5000)",
|
||||
};
|
||||
batchOption.DefaultValueFactory = _ => 5000;
|
||||
|
||||
var cmd = new Command("backfill-source-node")
|
||||
{
|
||||
Description = "Set SourceNode to a sentinel value on pre-feature rows where it is NULL (admin-only, maintenance path)",
|
||||
};
|
||||
cmd.Add(sentinelOption);
|
||||
cmd.Add(beforeOption);
|
||||
cmd.Add(batchOption);
|
||||
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
|
||||
if (connection.Error != null)
|
||||
{
|
||||
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sentinel = result.GetValue(sentinelOption) ?? "unknown";
|
||||
var before = result.GetValue(beforeOption)!;
|
||||
var batch = result.GetValue(batchOption);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sentinel))
|
||||
{
|
||||
OutputFormatter.WriteError("--sentinel must be a non-empty string.", "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (batch <= 0)
|
||||
{
|
||||
OutputFormatter.WriteError("--batch must be > 0.", "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var args = new AuditBackfillSourceNodeArgs
|
||||
{
|
||||
Sentinel = sentinel,
|
||||
Before = before,
|
||||
BatchSize = batch,
|
||||
};
|
||||
|
||||
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
||||
return await AuditBackfillHelpers.RunBackfillAsync(client, args, Console.Out);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,60 @@ public class ManagementHttpClient : IDisposable
|
||||
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a plain HTTP <c>POST</c> against a REST endpoint (e.g. the audit
|
||||
/// maintenance endpoints) with a JSON body and returns the response. Unlike
|
||||
/// <see cref="SendCommandAsync"/>, this does not wrap the call in the
|
||||
/// <c>POST /management</c> command envelope — these are plain REST resources.
|
||||
/// Authentication (HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL.</param>
|
||||
/// <param name="body">The JSON body to send, or <c>null</c> for an empty body.</param>
|
||||
/// <param name="timeout">The request timeout.</param>
|
||||
/// <returns>A management response containing status and data.</returns>
|
||||
public async Task<ManagementResponse> SendPostAsync(string relativePath, string? body, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
|
||||
var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
||||
}
|
||||
|
||||
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
|
||||
|
||||
if (httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
|
||||
}
|
||||
|
||||
string? error = null;
|
||||
string? code = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
||||
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = responseBody;
|
||||
}
|
||||
|
||||
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a plain HTTP <c>GET</c> and returns the raw <see cref="HttpResponseMessage"/>
|
||||
/// so the caller can stream the response body without buffering it in memory — used
|
||||
|
||||
Reference in New Issue
Block a user