refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
||||
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
||||
/// <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, exactly like
|
||||
/// the <c>audit query</c> subcommand.
|
||||
/// </summary>
|
||||
public sealed class AuditExportArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Start timestamp for the export time window.
|
||||
/// </summary>
|
||||
public string Since { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// End timestamp for the export time window.
|
||||
/// </summary>
|
||||
public string Until { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Export format (e.g., 'json', 'csv', 'parquet').
|
||||
/// </summary>
|
||||
public string Format { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Output file path for the exported audit log.
|
||||
/// </summary>
|
||||
public string Output { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Channel filter values (repeated query parameter).
|
||||
/// </summary>
|
||||
public string[] Channel { get; set; } = Array.Empty<string>();
|
||||
/// <summary>
|
||||
/// Kind filter values (repeated query parameter).
|
||||
/// </summary>
|
||||
public string[] Kind { get; set; } = Array.Empty<string>();
|
||||
/// <summary>
|
||||
/// Status filter values (repeated query parameter).
|
||||
/// </summary>
|
||||
public string[] Status { get; set; } = Array.Empty<string>();
|
||||
/// <summary>
|
||||
/// Site identifier filter values (repeated query parameter).
|
||||
/// </summary>
|
||||
public string[] Site { get; set; } = Array.Empty<string>();
|
||||
/// <summary>
|
||||
/// Optional target system filter.
|
||||
/// </summary>
|
||||
public string? Target { get; set; }
|
||||
/// <summary>
|
||||
/// Optional actor/user filter.
|
||||
/// </summary>
|
||||
public string? Actor { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit export</c> subcommand: builds the export query string and
|
||||
/// streams the HTTP response body straight to the destination file without buffering
|
||||
/// the (potentially multi-megabyte) export in memory.
|
||||
/// </summary>
|
||||
public static class AuditExportHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
||||
/// time window + format, plus optional filters. Time-specs are resolved via
|
||||
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. 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&channel=B</c>) so the
|
||||
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
|
||||
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
|
||||
/// </summary>
|
||||
/// <param name="args">The export arguments containing filters and format.</param>
|
||||
/// <param name="now">The current time for resolving relative time specifications.</param>
|
||||
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
|
||||
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
|
||||
Add("format", args.Format);
|
||||
AddEach("channel", args.Channel);
|
||||
AddEach("kind", args.Kind);
|
||||
AddEach("status", args.Status);
|
||||
AddEach("sourceSiteId", args.Site);
|
||||
Add("target", args.Target);
|
||||
Add("actor", args.Actor);
|
||||
|
||||
return "?" + string.Join("&", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the export: GETs <c>/api/audit/export</c> and copies the response body
|
||||
/// stream directly to <see cref="AuditExportArgs.Output"/>. The body is never fully
|
||||
/// buffered — <see cref="Stream.CopyToAsync(Stream)"/> streams in fixed-size chunks.
|
||||
/// A <c>501 Not Implemented</c> (parquet not yet supported server-side) prints the
|
||||
/// server message and returns a non-zero exit code.
|
||||
/// </summary>
|
||||
/// <param name="client">The management HTTP client for API communication.</param>
|
||||
/// <param name="args">The export arguments containing filters and output file path.</param>
|
||||
/// <param name="output">Text writer for command output messages.</param>
|
||||
/// <param name="now">The current time for resolving relative time specifications.</param>
|
||||
public static async Task<int> RunExportAsync(
|
||||
ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now)
|
||||
{
|
||||
var qs = BuildQueryString(args, now);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NotImplemented)
|
||||
{
|
||||
var message = await response.Content.ReadAsStringAsync();
|
||||
OutputFormatter.WriteError(
|
||||
string.IsNullOrWhiteSpace(message)
|
||||
? "Export format not implemented by the server."
|
||||
: message,
|
||||
"NOT_IMPLEMENTED");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var message = await response.Content.ReadAsStringAsync();
|
||||
// CLI-018: honour the documented "authorization failure → exit 2"
|
||||
// contract on the REST audit surface as well. HTTP 403 is the
|
||||
// primary signal; the server may also surface UNAUTHORIZED /
|
||||
// FORBIDDEN via the JSON error envelope on a non-403 status.
|
||||
var errorCode = TryExtractErrorCode(message);
|
||||
var isAuthFailure = (int)response.StatusCode == 403
|
||||
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(errorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
|
||||
OutputFormatter.WriteError(
|
||||
string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
|
||||
errorCode ?? "ERROR");
|
||||
return isAuthFailure ? 2 : 1;
|
||||
}
|
||||
|
||||
await using var source = await response.Content.ReadAsStreamAsync();
|
||||
await using var destination = new FileStream(
|
||||
args.Output, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
await source.CopyToAsync(destination);
|
||||
}
|
||||
|
||||
output.WriteLine($"Exported audit log to {args.Output}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort parse of the server's JSON error envelope (<c>{ "error": ..., "code": ... }</c>)
|
||||
/// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or
|
||||
/// has no <c>code</c> property — callers fall back to "ERROR" in that case.
|
||||
/// </summary>
|
||||
internal static string? TryExtractErrorCode(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("code", out var codeProp)
|
||||
&& codeProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return codeProp.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Body is not a JSON envelope (e.g. an HTML proxy error page); no code to extract.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user