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,99 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved CLI configuration combining config file values, environment variable overrides, and per-invocation credentials.
|
||||
/// </summary>
|
||||
public class CliConfig
|
||||
{
|
||||
/// <summary>Base URL of the ScadaBridge Management API (e.g. http://localhost:9000).</summary>
|
||||
public string? ManagementUrl { get; set; }
|
||||
/// <summary>Default output format for CLI commands; defaults to "json".</summary>
|
||||
public string DefaultFormat { get; set; } = "json";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
|
||||
/// Credentials are intentionally only sourced from environment variables (or the
|
||||
/// command line) — never from the config file — so they are not persisted to disk.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
|
||||
/// Provides a safer alternative to <c>--password</c>, which leaks into process
|
||||
/// listings and shell history.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads CLI configuration by merging the config file, environment variables, and credential env vars.
|
||||
/// </summary>
|
||||
/// <returns>A populated <see cref="CliConfig"/> instance.</returns>
|
||||
public static CliConfig Load()
|
||||
{
|
||||
var config = new CliConfig();
|
||||
|
||||
// Load from config file
|
||||
var configPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".scadabridge", "config.json");
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
// CLI-021: a malformed (`JsonException`), unreadable
|
||||
// (`UnauthorizedAccessException`), or otherwise faulted
|
||||
// (`IOException`) config file must not crash the CLI before any
|
||||
// command runs — even a command that supplies everything via
|
||||
// --url/--username/--password/--format on the command line still
|
||||
// calls Load() and would otherwise inherit the fault. Warn once on
|
||||
// stderr and fall through to the env-var + command-line precedence
|
||||
// chain with default settings.
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var fileConfig = JsonSerializer.Deserialize<CliConfigFile>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (fileConfig != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(fileConfig.ManagementUrl))
|
||||
config.ManagementUrl = fileConfig.ManagementUrl;
|
||||
if (!string.IsNullOrEmpty(fileConfig.DefaultFormat))
|
||||
config.DefaultFormat = fileConfig.DefaultFormat;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException || ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"warning: ignoring malformed or unreadable {configPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Override from environment variables
|
||||
var envUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
|
||||
if (!string.IsNullOrEmpty(envUrl))
|
||||
config.ManagementUrl = envUrl;
|
||||
|
||||
var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
|
||||
if (!string.IsNullOrEmpty(envFormat))
|
||||
config.DefaultFormat = envFormat;
|
||||
|
||||
// Credentials from environment variables only (never the config file).
|
||||
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
|
||||
if (!string.IsNullOrEmpty(envUsername))
|
||||
config.Username = envUsername;
|
||||
|
||||
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
|
||||
if (!string.IsNullOrEmpty(envPassword))
|
||||
config.Password = envPassword;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private class CliConfigFile
|
||||
{
|
||||
/// <summary>Management API URL from the config file.</summary>
|
||||
public string? ManagementUrl { get; set; }
|
||||
/// <summary>Default output format from the config file.</summary>
|
||||
public string? DefaultFormat { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class ApiMethodCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>api-method</c> CLI command group with subcommands for managing inbound API methods.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global option for the management URL.</param>
|
||||
/// <param name="formatOption">Global option for the output format.</param>
|
||||
/// <param name="usernameOption">Global option for the authentication username.</param>
|
||||
/// <param name="passwordOption">Global option for the authentication password.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("api-method") { Description = "Manage inbound API methods" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all API methods" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListApiMethodsCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get an API method by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetApiMethodCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
|
||||
var scriptOption = new Option<string>("--script") { Description = "Script code", Required = true };
|
||||
var timeoutOption = new Option<int>("--timeout") { Description = "Timeout in seconds" };
|
||||
timeoutOption.DefaultValueFactory = _ => 30;
|
||||
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create an API method" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(scriptOption);
|
||||
cmd.Add(timeoutOption);
|
||||
cmd.Add(parametersOption);
|
||||
cmd.Add(returnDefOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var script = result.GetValue(scriptOption)!;
|
||||
var timeout = result.GetValue(timeoutOption);
|
||||
var parameters = result.GetValue(parametersOption);
|
||||
var returnDef = result.GetValue(returnDefOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateApiMethodCommand(name, script, timeout, parameters, returnDef));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||
var scriptOption = new Option<string>("--script") { Description = "Script code", Required = true };
|
||||
var timeoutOption = new Option<int>("--timeout") { Description = "Timeout in seconds" };
|
||||
timeoutOption.DefaultValueFactory = _ => 30;
|
||||
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an API method" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(scriptOption);
|
||||
cmd.Add(timeoutOption);
|
||||
cmd.Add(parametersOption);
|
||||
cmd.Add(returnDefOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var script = result.GetValue(scriptOption)!;
|
||||
var timeout = result.GetValue(timeoutOption);
|
||||
var parameters = result.GetValue(parametersOption);
|
||||
var returnDef = result.GetValue(returnDefOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateApiMethodCommand(id, script, timeout, parameters, returnDef));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete an API method" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiMethodCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved Management API connection details for an <c>audit</c> subcommand, or an
|
||||
/// error describing why resolution failed.
|
||||
/// </summary>
|
||||
public sealed class AuditConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// The management URL, or null if resolution failed.
|
||||
/// </summary>
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The username for authentication, or null if resolution failed.
|
||||
/// </summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The password for authentication, or null if resolution failed.
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if resolution failed, or null.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if resolution failed, or null.
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed connection with an error message and code.
|
||||
/// </summary>
|
||||
/// <param name="error">The error message.</param>
|
||||
/// <param name="code">The error code.</param>
|
||||
/// <returns>A failed AuditConnection.</returns>
|
||||
public static AuditConnection Fail(string error, string code)
|
||||
=> new() { Error = error, ErrorCode = code };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection/format resolution shared by the <c>audit</c> subcommands. Mirrors the URL
|
||||
/// and credential precedence used by <see cref="CommandHelpers"/> (command line → config
|
||||
/// file / environment), but produces a raw <see cref="ManagementHttpClient"/> target
|
||||
/// because the audit endpoints are plain REST resources rather than <c>POST /management</c>
|
||||
/// command-envelope calls.
|
||||
/// </summary>
|
||||
public static class AuditCommandHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves management API connection details from command line arguments, config file, or environment variables.
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed command line arguments.</param>
|
||||
/// <param name="urlOption">The URL option.</param>
|
||||
/// <param name="usernameOption">The username option.</param>
|
||||
/// <param name="passwordOption">The password option.</param>
|
||||
/// <returns>The resolved connection details, or a failure result.</returns>
|
||||
public static AuditConnection ResolveConnection(
|
||||
ParseResult result,
|
||||
Option<string> urlOption,
|
||||
Option<string> usernameOption,
|
||||
Option<string> passwordOption)
|
||||
{
|
||||
var config = CliConfig.Load();
|
||||
|
||||
var url = result.GetValue(urlOption);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
url = config.ManagementUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return AuditConnection.Fail(
|
||||
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
|
||||
"NO_URL");
|
||||
}
|
||||
|
||||
if (!CommandHelpers.IsValidManagementUrl(url))
|
||||
{
|
||||
return AuditConnection.Fail(
|
||||
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
||||
"INVALID_URL");
|
||||
}
|
||||
|
||||
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
|
||||
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return AuditConnection.Fail(
|
||||
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
|
||||
"NO_CREDENTIALS");
|
||||
}
|
||||
|
||||
return new AuditConnection { Url = url, Username = username, Password = password };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the output format from command line arguments, config file, or defaults to "table".
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed command line arguments.</param>
|
||||
/// <param name="formatOption">The format option.</param>
|
||||
/// <returns>The resolved format string.</returns>
|
||||
public static string ResolveFormat(ParseResult result, Option<string> formatOption)
|
||||
=> CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>scadabridge audit</c> command group (Audit Log #23 M8). Provides read access to
|
||||
/// the centralized append-only Audit Log via the Bundle B REST endpoints
|
||||
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>), plus a v1 no-op
|
||||
/// <c>verify-chain</c> placeholder for the deferred hash-chain tamper-evidence feature.
|
||||
/// </summary>
|
||||
public static class AuditCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>audit</c> command group with query, export, and verify-chain sub-commands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
|
||||
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
|
||||
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
|
||||
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("audit") { Description = "Query and export the centralized audit log" };
|
||||
|
||||
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildQuery(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||
// --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
|
||||
// both repeated tokens (--channel A --channel B) and, with
|
||||
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||
var channelOption = new Option<string[]>("--channel")
|
||||
{
|
||||
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||
var kindOption = new Option<string[]>("--kind")
|
||||
{
|
||||
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
kindOption.AcceptOnlyFromAmong(
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||
var statusOption = new Option<string[]>("--status")
|
||||
{
|
||||
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
statusOption.AcceptOnlyFromAmong(
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||
var siteOption = new Option<string[]>("--site")
|
||||
{
|
||||
Description = "Filter by source site ID; repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
|
||||
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution ID" };
|
||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 100;
|
||||
var allOption = new Option<bool>("--all") { Description = "Fetch every page, following the keyset cursor" };
|
||||
|
||||
var cmd = new Command("query") { Description = "Query audit log events" };
|
||||
cmd.Add(sinceOption);
|
||||
cmd.Add(untilOption);
|
||||
cmd.Add(channelOption);
|
||||
cmd.Add(kindOption);
|
||||
cmd.Add(statusOption);
|
||||
cmd.Add(siteOption);
|
||||
cmd.Add(targetOption);
|
||||
cmd.Add(actorOption);
|
||||
cmd.Add(correlationIdOption);
|
||||
cmd.Add(executionIdOption);
|
||||
cmd.Add(parentExecutionIdOption);
|
||||
cmd.Add(errorsOnlyOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.Add(allOption);
|
||||
|
||||
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 format = AuditCommandHelpers.ResolveFormat(result, formatOption);
|
||||
var formatter = AuditFormatterFactory.Create(format, Console.Error);
|
||||
|
||||
var args = new AuditQueryArgs
|
||||
{
|
||||
Since = result.GetValue(sinceOption),
|
||||
Until = result.GetValue(untilOption),
|
||||
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
||||
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
||||
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
||||
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
||||
Target = result.GetValue(targetOption),
|
||||
Actor = result.GetValue(actorOption),
|
||||
CorrelationId = result.GetValue(correlationIdOption),
|
||||
ExecutionId = result.GetValue(executionIdOption),
|
||||
ParentExecutionId = result.GetValue(parentExecutionIdOption),
|
||||
ErrorsOnly = result.GetValue(errorsOnlyOption),
|
||||
PageSize = result.GetValue(pageSizeOption),
|
||||
};
|
||||
var fetchAll = result.GetValue(allOption);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
||||
return await AuditQueryHelpers.RunQueryAsync(
|
||||
client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildExport(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sinceOption = new Option<string>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var untilOption = new Option<string>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
||||
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
||||
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
||||
// --channel/--kind/--status/--site are multi-valued — same shape as the
|
||||
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
|
||||
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||
var channelOption = new Option<string[]>("--channel")
|
||||
{
|
||||
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||
var kindOption = new Option<string[]>("--kind")
|
||||
{
|
||||
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
kindOption.AcceptOnlyFromAmong(
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||
var statusOption = new Option<string[]>("--status")
|
||||
{
|
||||
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
statusOption.AcceptOnlyFromAmong(
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||
var siteOption = new Option<string[]>("--site")
|
||||
{
|
||||
Description = "Filter by source site ID; repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
|
||||
var cmd = new Command("export") { Description = "Export audit log events to a file" };
|
||||
cmd.Add(sinceOption);
|
||||
cmd.Add(untilOption);
|
||||
cmd.Add(formatExportOption);
|
||||
cmd.Add(outputOption);
|
||||
cmd.Add(channelOption);
|
||||
cmd.Add(kindOption);
|
||||
cmd.Add(statusOption);
|
||||
cmd.Add(siteOption);
|
||||
cmd.Add(targetOption);
|
||||
cmd.Add(actorOption);
|
||||
|
||||
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 args = new AuditExportArgs
|
||||
{
|
||||
Since = result.GetValue(sinceOption)!,
|
||||
Until = result.GetValue(untilOption)!,
|
||||
Format = result.GetValue(formatExportOption)!,
|
||||
Output = result.GetValue(outputOption)!,
|
||||
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
||||
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
||||
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
||||
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
||||
Target = result.GetValue(targetOption),
|
||||
Actor = result.GetValue(actorOption),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
||||
return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyChain(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var monthOption = new Option<string>("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
|
||||
|
||||
var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
|
||||
cmd.Add(monthOption);
|
||||
cmd.SetAction((ParseResult result) =>
|
||||
{
|
||||
var month = result.GetValue(monthOption)!;
|
||||
if (!AuditVerifyChainHelpers.IsValidMonth(month))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
$"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"Hash-chain tamper-evidence is not enabled in this release. "
|
||||
+ "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap.");
|
||||
return 0;
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a page of audit-log events to a writer. The <c>audit query</c> command picks
|
||||
/// a formatter from the <c>--format</c> option. The default JSONL formatter is defined
|
||||
/// here; the human-readable table formatter is supplied by Bundle C.
|
||||
/// </summary>
|
||||
public interface IAuditFormatter
|
||||
{
|
||||
/// <summary>Renders one page of events. Called once per fetched page.</summary>
|
||||
/// <param name="events">The audit events on this page.</param>
|
||||
/// <param name="output">Writer to render the formatted output to.</param>
|
||||
void WritePage(IReadOnlyList<JsonElement> events, TextWriter output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events
|
||||
/// are flushed as they arrive, so <c>--all</c> over many pages does not accumulate.
|
||||
/// </summary>
|
||||
public sealed class JsonLinesAuditFormatter : IAuditFormatter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
|
||||
{
|
||||
foreach (var evt in events)
|
||||
output.WriteLine(JsonSerializer.Serialize(evt, Compact));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value:
|
||||
/// <c>table</c> renders a column-aligned text table (<see cref="TableAuditFormatter"/>),
|
||||
/// any other value (including <c>json</c>) renders JSONL.
|
||||
/// </summary>
|
||||
public static class AuditFormatterFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an <see cref="IAuditFormatter"/> for the given format name.
|
||||
/// </summary>
|
||||
/// <param name="format">Format name; <c>table</c> selects the table formatter, any other value selects JSONL.</param>
|
||||
/// <param name="notices">Writer for notice messages emitted during formatting.</param>
|
||||
public static IAuditFormatter Create(string format, TextWriter notices)
|
||||
{
|
||||
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||
return new TableAuditFormatter();
|
||||
|
||||
return new JsonLinesAuditFormatter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>scadabridge audit-config</c> command group: views the configuration-change
|
||||
/// audit log (the <c>IAuditService</c> trail of admin edits — distinct from the
|
||||
/// centralized append-only Audit Log served by <see cref="AuditCommands"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Renamed from <c>audit-log</c> in #23 M8-T7 to avoid confusion with the new
|
||||
/// <c>scadabridge audit</c> group. The old <c>audit-log</c> name is retained as a
|
||||
/// deprecated alias; <see cref="DeprecatedAlias"/> still resolves the full subcommand
|
||||
/// tree, and <c>Program.cs</c> prints a deprecation warning when it is used.
|
||||
/// </remarks>
|
||||
public static class AuditLogCommands
|
||||
{
|
||||
/// <summary>The deprecated alias kept for backward compatibility with the old command name.</summary>
|
||||
public const string DeprecatedAlias = "audit-log";
|
||||
|
||||
/// <summary>The deprecation warning emitted when the old <c>audit-log</c> name is used.</summary>
|
||||
public const string DeprecationWarning =
|
||||
"Warning: 'audit-log' is deprecated and will be removed in a future release. "
|
||||
+ "Use 'audit-config' instead.";
|
||||
|
||||
/// <summary>
|
||||
/// Writes the <see cref="DeprecationWarning"/> to <paramref name="stderr"/> when the
|
||||
/// CLI was invoked via the deprecated <c>audit-log</c> command name (i.e. the first
|
||||
/// argument is <see cref="DeprecatedAlias"/>). The command itself still works — it is
|
||||
/// an alias of <c>audit-config</c> — so this only adds the migration warning.
|
||||
/// Factored out of <c>Program.cs</c> so it is unit-testable without spawning a process.
|
||||
/// </summary>
|
||||
/// <param name="args">The raw command-line arguments passed to the CLI.</param>
|
||||
/// <param name="stderr">The text writer to emit the deprecation warning to.</param>
|
||||
public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr)
|
||||
{
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], DeprecatedAlias, StringComparison.Ordinal))
|
||||
{
|
||||
stderr.WriteLine(DeprecationWarning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>audit-config</c> command (with the deprecated <c>audit-log</c> alias) and its subcommands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global management URL option.</param>
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username option.</param>
|
||||
/// <param name="passwordOption">Global password option.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
|
||||
// Backward-compatible alias for the pre-M8 `audit-log` name. The alias keeps
|
||||
// full subcommand parity automatically; the deprecation warning is emitted by
|
||||
// the args[0] check in Program.cs.
|
||||
command.Aliases.Add(DeprecatedAlias);
|
||||
|
||||
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildQuery(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var userOption = new Option<string?>("--user") { Description = "Filter by username" };
|
||||
var entityTypeOption = new Option<string?>("--entity-type") { Description = "Filter by entity type" };
|
||||
var actionOption = new Option<string?>("--action") { Description = "Filter by action" };
|
||||
var fromOption = new Option<DateTimeOffset?>("--from") { Description = "Start date (ISO 8601)" };
|
||||
var toOption = new Option<DateTimeOffset?>("--to") { Description = "End date (ISO 8601)" };
|
||||
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
|
||||
var cmd = new Command("query") { Description = "Query audit log entries" };
|
||||
cmd.Add(userOption);
|
||||
cmd.Add(entityTypeOption);
|
||||
cmd.Add(actionOption);
|
||||
cmd.Add(fromOption);
|
||||
cmd.Add(toOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var user = result.GetValue(userOption);
|
||||
var entityType = result.GetValue(entityTypeOption);
|
||||
var action = result.GetValue(actionOption);
|
||||
var from = result.GetValue(fromOption);
|
||||
var to = result.GetValue(toOption);
|
||||
var page = result.GetValue(pageOption);
|
||||
var pageSize = result.GetValue(pageSizeOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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
|
||||
{
|
||||
/// <summary>Start time spec (relative like 1h, or absolute ISO-8601).</summary>
|
||||
public string? Since { get; set; }
|
||||
/// <summary>End time spec (relative like 7d, or absolute ISO-8601).</summary>
|
||||
public string? Until { get; set; }
|
||||
/// <summary>Multi-valued channel filter.</summary>
|
||||
public string[] Channel { get; set; } = Array.Empty<string>();
|
||||
/// <summary>Multi-valued audit event kind filter.</summary>
|
||||
public string[] Kind { get; set; } = Array.Empty<string>();
|
||||
/// <summary>Multi-valued status filter.</summary>
|
||||
public string[] Status { get; set; } = Array.Empty<string>();
|
||||
/// <summary>Multi-valued site ID filter.</summary>
|
||||
public string[] Site { get; set; } = Array.Empty<string>();
|
||||
/// <summary>Target system or service filter.</summary>
|
||||
public string? Target { get; set; }
|
||||
/// <summary>Actor (user or system) filter.</summary>
|
||||
public string? Actor { get; set; }
|
||||
/// <summary>Operation correlation ID filter.</summary>
|
||||
public string? CorrelationId { get; set; }
|
||||
/// <summary>Script execution ID filter.</summary>
|
||||
public string? ExecutionId { get; set; }
|
||||
/// <summary>Parent execution ID filter.</summary>
|
||||
public string? ParentExecutionId { get; set; }
|
||||
/// <summary>Filter for errors only (status=Failed).</summary>
|
||||
public bool ErrorsOnly { get; set; }
|
||||
/// <summary>Page size for pagination.</summary>
|
||||
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>
|
||||
/// <param name="spec">The time specification string.</param>
|
||||
/// <param name="now">The current time used as reference for relative specs.</param>
|
||||
/// <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&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>
|
||||
/// <param name="args">The audit query arguments.</param>
|
||||
/// <param name="now">The current time for resolving relative time specs.</param>
|
||||
/// <param name="afterOccurredAtUtc">Optional keyset cursor timestamp.</param>
|
||||
/// <param name="afterEventId">Optional keyset cursor event ID.</param>
|
||||
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("parentExecutionId", args.ParentExecutionId);
|
||||
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>
|
||||
/// <param name="client">The management HTTP client.</param>
|
||||
/// <param name="args">The audit query arguments.</param>
|
||||
/// <param name="fetchAll">Whether to follow pagination cursors.</param>
|
||||
/// <param name="formatter">The audit result formatter.</param>
|
||||
/// <param name="output">The output writer for results.</param>
|
||||
/// <param name="now">The current time for resolving relative time specs.</param>
|
||||
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");
|
||||
// CLI-018: surface the documented "authorization failure → exit 2"
|
||||
// contract for the audit REST surface too, not just /management.
|
||||
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit verify-chain</c> subcommand. v1 is a no-op: hash-chain
|
||||
/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
|
||||
/// validates its <c>--month</c> argument so the surface is stable for v1.x.
|
||||
/// </summary>
|
||||
public static class AuditVerifyChainHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
|
||||
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
|
||||
/// </summary>
|
||||
/// <param name="month">The month string to validate in YYYY-MM format.</param>
|
||||
public static bool IsValidMonth(string? month)
|
||||
=> !string.IsNullOrWhiteSpace(month)
|
||||
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out _);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Transport (#24) bundle export / preview / import. The bundle bytes travel
|
||||
/// through the management endpoint as base64 inside the standard JSON envelope
|
||||
/// so no transport plumbing diverges from the other commands; the CLI handles
|
||||
/// file I/O at the edges.
|
||||
/// </summary>
|
||||
public static class BundleCommands
|
||||
{
|
||||
private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Builds the <c>bundle</c> command group with export, preview, and import sub-commands.</summary>
|
||||
/// <param name="urlOption">Shared management URL option.</param>
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username option.</param>
|
||||
/// <param name="passwordOption">Shared password option.</param>
|
||||
/// <returns>The configured <see cref="Command"/> for the bundle group.</returns>
|
||||
public static Command Build(
|
||||
Option<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("bundle")
|
||||
{
|
||||
Description = "Export, preview, and import Transport bundles",
|
||||
};
|
||||
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildPreview(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildImport(urlOption, formatOption, usernameOption, passwordOption));
|
||||
return command;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// bundle export
|
||||
// ====================================================================
|
||||
private static Command BuildExport(
|
||||
Option<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var outputOption = new Option<string>("--output")
|
||||
{
|
||||
Description = "Output file path (.scadabundle)",
|
||||
Required = true,
|
||||
};
|
||||
var passphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "Encryption passphrase. Omit to produce an unencrypted bundle.",
|
||||
};
|
||||
var allOption = new Option<bool>("--all")
|
||||
{
|
||||
Description = "Export every entity of every supported type (ignores per-type name flags).",
|
||||
};
|
||||
var templatesOption = NameListOption("--templates", "Comma-separated template names");
|
||||
var sharedScriptsOption = NameListOption("--shared-scripts", "Comma-separated shared-script names");
|
||||
var externalSystemsOption = NameListOption("--external-systems", "Comma-separated external-system names");
|
||||
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
|
||||
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
|
||||
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
|
||||
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names");
|
||||
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
|
||||
var includeDepsOption = new Option<bool>("--include-dependencies")
|
||||
{
|
||||
Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.",
|
||||
};
|
||||
var sourceEnvOption = new Option<string?>("--source-environment")
|
||||
{
|
||||
Description = "SourceEnvironment value stamped into the bundle manifest. Defaults to 'cli'.",
|
||||
};
|
||||
|
||||
var cmd = new Command("export")
|
||||
{
|
||||
Description = "Export a bundle to a file",
|
||||
};
|
||||
cmd.Add(outputOption);
|
||||
cmd.Add(passphraseOption);
|
||||
cmd.Add(allOption);
|
||||
cmd.Add(templatesOption);
|
||||
cmd.Add(sharedScriptsOption);
|
||||
cmd.Add(externalSystemsOption);
|
||||
cmd.Add(dbConnectionsOption);
|
||||
cmd.Add(notificationListsOption);
|
||||
cmd.Add(smtpConfigsOption);
|
||||
cmd.Add(apiKeysOption);
|
||||
cmd.Add(apiMethodsOption);
|
||||
cmd.Add(includeDepsOption);
|
||||
cmd.Add(sourceEnvOption);
|
||||
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var output = result.GetValue(outputOption)!;
|
||||
var passphrase = result.GetValue(passphraseOption);
|
||||
var all = result.GetValue(allOption);
|
||||
var includeDeps = result.GetValue(includeDepsOption);
|
||||
var sourceEnv = result.GetValue(sourceEnvOption) ?? "cli";
|
||||
|
||||
var payload = new ExportBundleCommand(
|
||||
All: all,
|
||||
TemplateNames: result.GetValue(templatesOption),
|
||||
SharedScriptNames: result.GetValue(sharedScriptsOption),
|
||||
ExternalSystemNames: result.GetValue(externalSystemsOption),
|
||||
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
|
||||
NotificationListNames: result.GetValue(notificationListsOption),
|
||||
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
|
||||
ApiKeyNames: result.GetValue(apiKeysOption),
|
||||
ApiMethodNames: result.GetValue(apiMethodsOption),
|
||||
IncludeDependencies: includeDeps,
|
||||
Passphrase: passphrase,
|
||||
SourceEnvironment: sourceEnv);
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
payload,
|
||||
timeout: BundleCommandTimeout,
|
||||
onSuccess: jsonOk =>
|
||||
{
|
||||
// CLI-020: previously the JSON envelope parse + property extraction +
|
||||
// base64 decode all ran unguarded — a server-side bug that omits one of
|
||||
// the two expected properties, returns a null base64 value, sends invalid
|
||||
// base64, or returns a malformed JSON envelope would surface as one of
|
||||
// KeyNotFoundException / InvalidOperationException / FormatException /
|
||||
// JsonException, i.e. an unhandled stack trace rather than the
|
||||
// documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the
|
||||
// envelope parse and the streamed write in a single try/catch matching
|
||||
// the graceful-degradation theme established by CLI-002 / CLI-003 / CLI-005.
|
||||
string base64;
|
||||
int byteCount;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonOk);
|
||||
base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!;
|
||||
byteCount = doc.RootElement.GetProperty("byteCount").GetInt32();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException
|
||||
or KeyNotFoundException
|
||||
or InvalidOperationException)
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
$"Server returned a malformed bundle-export response: {ex.Message}",
|
||||
"INVALID_RESPONSE");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// CLI-019: stream the base64 → file write so a 100 MB bundle
|
||||
// doesn't double-buffer through Convert.FromBase64String's
|
||||
// ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes.
|
||||
// The management envelope's body is still buffered into the
|
||||
// jsonOk string (wire-format limit), but the decode + write
|
||||
// are now chunked, so peak working-set drops from
|
||||
// ~base64+byte[]+envelope to ~base64+small-chunk.
|
||||
long written;
|
||||
try
|
||||
{
|
||||
written = StreamBase64ToFile(base64, output);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
$"Server returned invalid base64 in the bundle response: {ex.Message}",
|
||||
"INVALID_RESPONSE");
|
||||
return 1;
|
||||
}
|
||||
Console.WriteLine($"Wrote {written:N0} bytes to {output} (server reported {byteCount:N0}).");
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// bundle preview
|
||||
// ====================================================================
|
||||
private static Command BuildPreview(
|
||||
Option<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var inputOption = new Option<string>("--input")
|
||||
{
|
||||
Description = "Bundle file path (.scadabundle)",
|
||||
Required = true,
|
||||
};
|
||||
var passphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "Passphrase for encrypted bundles.",
|
||||
};
|
||||
|
||||
var cmd = new Command("preview")
|
||||
{
|
||||
Description = "Load a bundle and print the diff preview without applying",
|
||||
};
|
||||
cmd.Add(inputOption);
|
||||
cmd.Add(passphraseOption);
|
||||
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var input = result.GetValue(inputOption)!;
|
||||
if (!File.Exists(input))
|
||||
{
|
||||
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
|
||||
return 1;
|
||||
}
|
||||
var bytes = await File.ReadAllBytesAsync(input);
|
||||
var payload = new PreviewBundleCommand(
|
||||
Base64Bundle: Convert.ToBase64String(bytes),
|
||||
Passphrase: result.GetValue(passphraseOption));
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
payload,
|
||||
timeout: BundleCommandTimeout,
|
||||
onSuccess: jsonOk =>
|
||||
{
|
||||
Console.WriteLine(jsonOk);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// bundle import
|
||||
// ====================================================================
|
||||
private static Command BuildImport(
|
||||
Option<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var inputOption = new Option<string>("--input")
|
||||
{
|
||||
Description = "Bundle file path (.scadabundle)",
|
||||
Required = true,
|
||||
};
|
||||
var passphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "Passphrase for encrypted bundles.",
|
||||
};
|
||||
var onConflictOption = new Option<string>("--on-conflict")
|
||||
{
|
||||
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
|
||||
DefaultValueFactory = _ => "overwrite",
|
||||
};
|
||||
|
||||
var cmd = new Command("import")
|
||||
{
|
||||
Description = "Load + apply a bundle with a single global conflict policy",
|
||||
};
|
||||
cmd.Add(inputOption);
|
||||
cmd.Add(passphraseOption);
|
||||
cmd.Add(onConflictOption);
|
||||
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var input = result.GetValue(inputOption)!;
|
||||
if (!File.Exists(input))
|
||||
{
|
||||
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
|
||||
return 1;
|
||||
}
|
||||
var bytes = await File.ReadAllBytesAsync(input);
|
||||
var payload = new ImportBundleCommand(
|
||||
Base64Bundle: Convert.ToBase64String(bytes),
|
||||
Passphrase: result.GetValue(passphraseOption),
|
||||
DefaultConflictPolicy: result.GetValue(onConflictOption)!);
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
payload,
|
||||
timeout: BundleCommandTimeout,
|
||||
onSuccess: jsonOk =>
|
||||
{
|
||||
Console.WriteLine(jsonOk);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Shared HTTP plumbing
|
||||
// ====================================================================
|
||||
//
|
||||
// CLI-017: bundle commands previously routed through a private
|
||||
// RunBundleCommandAsync that re-implemented URL/credential resolution and
|
||||
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
|
||||
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
|
||||
// code on a non-403 status would exit 1 instead of the documented exit 2.
|
||||
// The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with
|
||||
// the longer BundleCommandTimeout and a per-command success handler, so the
|
||||
// exit-code contract is unified across every command group.
|
||||
|
||||
// CLI-019: chunked base64 → file streaming. The management envelope's
|
||||
// success body is a single buffered JSON string (the wire format does not
|
||||
// currently support response-body streaming), so we cannot remove the
|
||||
// ~base64-string + ~envelope-string allocation. What we CAN — and do —
|
||||
// remove is the intermediate ~bytecount-sized byte[] that
|
||||
// Convert.FromBase64String allocates plus the synchronous File.WriteAllBytes:
|
||||
// we slice the base64 string into 4-byte-multiple chunks (4 base64 chars
|
||||
// decode into exactly 3 bytes, so any multiple of 4 is a clean boundary)
|
||||
// and decode each chunk into a small rented buffer that we copy into the
|
||||
// output FileStream. The chunk size is a tradeoff — large enough that the
|
||||
// per-chunk loop overhead is negligible, small enough that we never put
|
||||
// anything on the LOH (1 MB is below the 85 KB LOH threshold's larger
|
||||
// cousin for buffers we don't keep). Returns the total decoded byte count
|
||||
// for the post-write summary line.
|
||||
internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded
|
||||
|
||||
internal static long StreamBase64ToFile(string base64, string outputPath)
|
||||
{
|
||||
if (base64 is null) throw new ArgumentNullException(nameof(base64));
|
||||
if (string.IsNullOrEmpty(outputPath)) throw new ArgumentException("Output path required.", nameof(outputPath));
|
||||
|
||||
// Skip any leading whitespace and trailing padding noise. Convert.TryFromBase64Chars
|
||||
// tolerates internal whitespace, but slicing on arbitrary positions would split a
|
||||
// run of base64 chars mid-quad — round the chunk to a multiple of 4 so each slice
|
||||
// is independently decodable.
|
||||
var chunkChars = Base64StreamChunkChars - (Base64StreamChunkChars % 4);
|
||||
var totalChars = base64.Length;
|
||||
var totalWritten = 0L;
|
||||
|
||||
using var fileStream = new FileStream(
|
||||
outputPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: false);
|
||||
|
||||
// 4 base64 chars = 3 bytes, so the decoded buffer is sized accordingly.
|
||||
var byteBuffer = new byte[(chunkChars / 4) * 3];
|
||||
|
||||
for (var offset = 0; offset < totalChars; offset += chunkChars)
|
||||
{
|
||||
var take = Math.Min(chunkChars, totalChars - offset);
|
||||
var slice = base64.AsSpan(offset, take);
|
||||
|
||||
// The final slice may be shorter than chunkChars and may carry
|
||||
// trailing '=' padding; TryFromBase64Chars handles that.
|
||||
if (!Convert.TryFromBase64Chars(slice, byteBuffer, out var written))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Bundle response contained invalid base64 at character offset {offset}.");
|
||||
}
|
||||
fileStream.Write(byteBuffer, 0, written);
|
||||
totalWritten += written;
|
||||
}
|
||||
|
||||
return totalWritten;
|
||||
}
|
||||
|
||||
private static Option<IReadOnlyList<string>?> NameListOption(string name, string description)
|
||||
{
|
||||
var opt = new Option<IReadOnlyList<string>?>(name)
|
||||
{
|
||||
Description = description,
|
||||
CustomParser = arg =>
|
||||
{
|
||||
var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value;
|
||||
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||
return token
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
},
|
||||
};
|
||||
return opt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.CommandLine;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Factory methods for the global CLI options. Centralising option construction keeps
|
||||
/// validation rules (e.g. the accepted <c>--format</c> values) in one place and makes
|
||||
/// them testable without standing up the whole command tree.
|
||||
/// </summary>
|
||||
internal static class CliOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the global <c>--format</c> option. The option deliberately has no
|
||||
/// <c>DefaultValueFactory</c> — format precedence (explicit flag → config/env →
|
||||
/// <c>"json"</c>) is resolved by <see cref="CommandHelpers.ResolveFormat"/>, which
|
||||
/// needs to distinguish an absent flag. The accepted values are constrained so a
|
||||
/// typo (e.g. <c>--format tabel</c>) is rejected with a clear parse error rather
|
||||
/// than silently falling through to JSON.
|
||||
/// </summary>
|
||||
internal static Option<string> CreateFormatOption()
|
||||
{
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format (json or table)",
|
||||
Recursive = true,
|
||||
};
|
||||
formatOption.AcceptOnlyFromAmong("json", "table");
|
||||
return formatOption;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
internal static class CommandHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the management URL, credentials, and output format, then sends <paramref name="command"/>
|
||||
/// to the management API and returns the process exit code.
|
||||
/// </summary>
|
||||
/// <param name="result">Parsed command-line result from which option values are read.</param>
|
||||
/// <param name="urlOption">Option that supplies the management URL override.</param>
|
||||
/// <param name="formatOption">Option that supplies the output format override.</param>
|
||||
/// <param name="usernameOption">Option that supplies the username override.</param>
|
||||
/// <param name="passwordOption">Option that supplies the password override.</param>
|
||||
/// <param name="command">The management command object to send.</param>
|
||||
/// <param name="timeout">
|
||||
/// Optional per-command HTTP timeout. Defaults to 30s, matching the management API's
|
||||
/// own request timeout. Larger payloads (e.g. Transport bundles) should supply a
|
||||
/// longer value.
|
||||
/// </param>
|
||||
/// <param name="onSuccess">
|
||||
/// Optional success handler. When supplied, the helper invokes it with the success
|
||||
/// body instead of running the default <see cref="HandleResponse"/> rendering path —
|
||||
/// useful when the caller needs to capture the response (e.g. write a file) rather
|
||||
/// than print it. The authorization-failure exit-code contract
|
||||
/// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way,
|
||||
/// closing CLI-017's regression.
|
||||
/// </param>
|
||||
internal static async Task<int> ExecuteCommandAsync(
|
||||
ParseResult result,
|
||||
Option<string> urlOption,
|
||||
Option<string> formatOption,
|
||||
Option<string> usernameOption,
|
||||
Option<string> passwordOption,
|
||||
object command,
|
||||
TimeSpan? timeout = null,
|
||||
Func<string, int>? onSuccess = null)
|
||||
{
|
||||
var config = CliConfig.Load();
|
||||
var format = ResolveFormat(result, formatOption, config);
|
||||
|
||||
// Resolve management URL
|
||||
var url = result.GetValue(urlOption);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
url = config.ManagementUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
|
||||
"NO_URL");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!IsValidManagementUrl(url))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
||||
"INVALID_URL");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Resolve credentials: command-line options take precedence, then the
|
||||
// SCADALINK_USERNAME / SCADALINK_PASSWORD environment variables.
|
||||
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
|
||||
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
|
||||
"NO_CREDENTIALS");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Derive command name from type
|
||||
var commandName = ManagementCommandRegistry.GetCommandName(command.GetType());
|
||||
|
||||
// Send via HTTP
|
||||
using var client = new ManagementHttpClient(url, username, password);
|
||||
var response = await client.SendCommandAsync(commandName, command, timeout ?? TimeSpan.FromSeconds(30));
|
||||
|
||||
// Caller-supplied success handler short-circuits the default rendering — but
|
||||
// the error path still routes through IsAuthorizationFailure so the documented
|
||||
// exit-2 contract holds whether or not a custom handler is provided
|
||||
// (CLI-017 unification of the bundle path).
|
||||
if (onSuccess is not null)
|
||||
{
|
||||
if (response.JsonData is not null)
|
||||
return onSuccess(response.JsonData);
|
||||
|
||||
OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR");
|
||||
return IsAuthorizationFailure(response) ? 2 : 1;
|
||||
}
|
||||
|
||||
return HandleResponse(response, format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the output format using the documented precedence chain:
|
||||
/// an explicitly supplied <c>--format</c> option wins, otherwise the
|
||||
/// config-file / environment-variable default (<see cref="CliConfig.DefaultFormat"/>)
|
||||
/// is used, otherwise <c>json</c>. The <c>--format</c> option must not declare a
|
||||
/// <c>DefaultValueFactory</c> — that would mask whether the flag was supplied.
|
||||
/// </summary>
|
||||
/// <param name="result">Parsed command-line result.</param>
|
||||
/// <param name="formatOption">The <c>--format</c> option definition.</param>
|
||||
/// <param name="config">Loaded CLI configuration providing the default format fallback.</param>
|
||||
internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config)
|
||||
{
|
||||
// GetResult returns non-null only when the option was actually present on the
|
||||
// command line, letting an explicit --format override the config default.
|
||||
if (result.GetResult(formatOption) != null)
|
||||
{
|
||||
var explicitValue = result.GetValue(formatOption);
|
||||
if (!string.IsNullOrWhiteSpace(explicitValue))
|
||||
return explicitValue;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single credential: an explicit command-line value wins, otherwise the
|
||||
/// environment-variable fallback (from <see cref="CliConfig"/>) is used.
|
||||
/// </summary>
|
||||
/// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param>
|
||||
/// <param name="envValue">Fallback value from the config file or environment variable.</param>
|
||||
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
|
||||
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a management URL is an absolute http/https URL. A malformed URL
|
||||
/// (missing scheme, empty, or a non-http scheme) would otherwise reach
|
||||
/// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
|
||||
/// an unhandled <see cref="UriFormatException"/>.
|
||||
/// </summary>
|
||||
/// <param name="url">URL string to validate.</param>
|
||||
internal static bool IsValidManagementUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return false;
|
||||
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the management response to stdout and returns the appropriate process exit code.
|
||||
/// </summary>
|
||||
/// <param name="response">Response received from the management API.</param>
|
||||
/// <param name="format">Output format (<c>json</c> or <c>table</c>).</param>
|
||||
internal static int HandleResponse(ManagementResponse response, string format)
|
||||
{
|
||||
if (response.JsonData != null)
|
||||
{
|
||||
// A success status with an empty/whitespace body (e.g. a 204 from a delete)
|
||||
// is a "command succeeded, no output" case — do not attempt to parse it.
|
||||
if (string.IsNullOrWhiteSpace(response.JsonData))
|
||||
{
|
||||
Console.WriteLine("(ok)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WriteAsTable(response.JsonData);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(response.JsonData);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
var errorCode = response.ErrorCode ?? "ERROR";
|
||||
var error = response.Error ?? "Unknown error";
|
||||
|
||||
OutputFormatter.WriteError(error, errorCode);
|
||||
return IsAuthorizationFailure(response) ? 2 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an error response represents an authorization failure
|
||||
/// (insufficient role), which the documented exit-code table maps to exit code 2.
|
||||
/// An HTTP 403 status is the primary signal; the server may also signal it via an
|
||||
/// <c>UNAUTHORIZED</c> / <c>FORBIDDEN</c> error code on a different HTTP status, so
|
||||
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
|
||||
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
|
||||
/// </summary>
|
||||
internal static bool IsAuthorizationFailure(ManagementResponse response)
|
||||
{
|
||||
if (response.StatusCode == 403)
|
||||
return true;
|
||||
|
||||
return string.Equals(response.ErrorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(response.ErrorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void WriteAsTable(string json)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// The server returned a success status but a non-JSON body (e.g. a proxy
|
||||
// HTML error page, or a plain-text message). Print it verbatim rather than
|
||||
// crashing — mirrors the raw-body fallback on the JSON path.
|
||||
Console.WriteLine(json);
|
||||
return;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var items = root.EnumerateArray().ToList();
|
||||
if (items.Count == 0)
|
||||
{
|
||||
Console.WriteLine("(no results)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive the header set as the union of property names across *every*
|
||||
// element, in first-seen order. Using only items[0] would silently drop
|
||||
// columns for any later element with a different shape (CLI-016).
|
||||
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
|
||||
string[] headers;
|
||||
if (objectItems.Count > 0)
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var known = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in objectItems)
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
if (known.Add(prop.Name))
|
||||
seen.Add(prop.Name);
|
||||
headers = seen.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
headers = new[] { "Value" };
|
||||
}
|
||||
|
||||
var rows = items.Select(item =>
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return headers.Select(h =>
|
||||
item.TryGetProperty(h, out var val)
|
||||
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
|
||||
: "").ToArray();
|
||||
}
|
||||
return new[] { item.ToString() };
|
||||
});
|
||||
|
||||
OutputFormatter.WriteTable(rows, headers);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var headers = new[] { "Property", "Value" };
|
||||
var rows = root.EnumerateObject().Select(p =>
|
||||
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
|
||||
OutputFormatter.WriteTable(rows, headers);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(root.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class DataConnectionCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>data-connection</c> command group and all its subcommands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global management URL option.</param>
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username option.</param>
|
||||
/// <param name="passwordOption">Global password option.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("data-connection") { Description = "Manage data connections" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get a data connection by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetDataConnectionCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var protocolOption = new Option<string>("--protocol") { Description = "Protocol", Required = true };
|
||||
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
|
||||
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
|
||||
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update a data connection" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(protocolOption);
|
||||
cmd.Add(configOption);
|
||||
cmd.Add(backupConfigOption);
|
||||
cmd.Add(failoverRetryOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var protocol = result.GetValue(protocolOption)!;
|
||||
var config = result.GetValue(configOption);
|
||||
var backupConfig = result.GetValue(backupConfigOption);
|
||||
var failoverRetryCount = result.GetValue(failoverRetryOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateDataConnectionCommand(id, name, protocol, config, backupConfig, failoverRetryCount));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
||||
var cmd = new Command("list") { Description = "List data connections" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand(siteId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
|
||||
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
|
||||
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
|
||||
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a new data connection" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(protocolOption);
|
||||
cmd.Add(configOption);
|
||||
cmd.Add(backupConfigOption);
|
||||
cmd.Add(failoverRetryOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var protocol = result.GetValue(protocolOption)!;
|
||||
var config = result.GetValue(configOption);
|
||||
var backupConfig = result.GetValue(backupConfigOption);
|
||||
var failoverRetryCount = result.GetValue(failoverRetryOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateDataConnectionCommand(siteId, name, protocol, config, backupConfig, failoverRetryCount));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete a data connection" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteDataConnectionCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for managing database connection definitions.
|
||||
/// </summary>
|
||||
public static class DbConnectionCommands
|
||||
{
|
||||
/// <summary>Builds the <c>db-connection</c> command with list, get, create, update, and delete sub-commands.</summary>
|
||||
/// <param name="urlOption">Global URL option.</param>
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username option.</param>
|
||||
/// <param name="passwordOption">Global password option.</param>
|
||||
/// <returns>The configured <c>db-connection</c> command.</returns>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("db-connection") { Description = "Manage database connections" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all database connections" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListDatabaseConnectionsCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get a database connection by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetDatabaseConnectionCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", Required = true };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a database connection" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(connStrOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var connStr = result.GetValue(connStrOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateDatabaseConnectionDefCommand(name, connStr));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", Required = true };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update a database connection" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(connStrOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var connStr = result.GetValue(connStrOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateDatabaseConnectionDefCommand(id, name, connStr));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete a database connection" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteDatabaseConnectionDefCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class DebugCommands
|
||||
{
|
||||
/// <summary>Builds the <c>debug</c> command with its subcommands using the given shared CLI options.</summary>
|
||||
/// <param name="urlOption">Shared management URL option.</param>
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username option for authentication.</param>
|
||||
/// <param name="passwordOption">Shared password option for authentication.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("debug") { Description = "Runtime debugging" };
|
||||
|
||||
command.Add(BuildSnapshot(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildStream(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildSnapshot(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("snapshot") { Description = "Get a point-in-time snapshot of instance attribute values and alarm states" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DebugSnapshotCommand(result.GetValue(idOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildStream(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("stream") { Description = "Stream live attribute values and alarm states in real-time (Ctrl+C to stop)" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var instanceId = result.GetValue(idOption);
|
||||
var config = CliConfig.Load();
|
||||
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||
|
||||
var url = result.GetValue(urlOption);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
url = config.ManagementUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
|
||||
"NO_URL");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!CommandHelpers.IsValidManagementUrl(url))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
||||
"INVALID_URL");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
|
||||
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
OutputFormatter.WriteError(
|
||||
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
|
||||
"NO_CREDENTIALS");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return await StreamDebugAsync(url, username, password, instanceId, format);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
|
||||
{
|
||||
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
|
||||
var connection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, options =>
|
||||
{
|
||||
options.Headers.Add("Authorization", $"Basic {credentials}");
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
// CLI-011: CancellationTokenSource owns a WaitHandle and must be disposed.
|
||||
using var cts = new CancellationTokenSource();
|
||||
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
var isTable = string.Equals(format, "table", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Register event handlers
|
||||
connection.On<JsonElement>("OnSnapshot", snapshot =>
|
||||
{
|
||||
if (isTable)
|
||||
{
|
||||
Console.WriteLine("=== Initial Snapshot ===");
|
||||
PrintSnapshotTable(snapshot);
|
||||
Console.WriteLine("=== Streaming (Ctrl+C to stop) ===");
|
||||
}
|
||||
else
|
||||
{
|
||||
var obj = new { type = "snapshot", data = snapshot };
|
||||
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
|
||||
}
|
||||
});
|
||||
|
||||
connection.On<JsonElement>("OnAttributeChanged", changed =>
|
||||
{
|
||||
if (isTable)
|
||||
{
|
||||
var name = changed.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
|
||||
var value = changed.TryGetProperty("value", out var v) ? v.ToString() : "?";
|
||||
var quality = changed.TryGetProperty("quality", out var q) ? q.GetString() : "?";
|
||||
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
||||
Console.WriteLine($" ATTR {name,-30} {value,-20} {quality,-10} {ts}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var obj = new { type = "attributeChanged", data = changed };
|
||||
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
|
||||
}
|
||||
});
|
||||
|
||||
connection.On<JsonElement>("OnAlarmChanged", changed =>
|
||||
{
|
||||
if (isTable)
|
||||
{
|
||||
var name = changed.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
|
||||
var state = changed.TryGetProperty("state", out var s) ? s.ToString() : "?";
|
||||
var priority = changed.TryGetProperty("priority", out var p) ? p.ToString() : "?";
|
||||
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
||||
Console.WriteLine($" ALARM {name,-30} {state,-20} P{priority,-9} {ts}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var obj = new { type = "alarmChanged", data = changed };
|
||||
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
|
||||
}
|
||||
});
|
||||
|
||||
connection.On<string>("OnStreamTerminated", reason =>
|
||||
{
|
||||
Console.Error.WriteLine($"Stream terminated: {reason}");
|
||||
exitTcs.TrySetResult(1);
|
||||
});
|
||||
|
||||
connection.Closed += ex =>
|
||||
{
|
||||
if (!cts.IsCancellationRequested)
|
||||
{
|
||||
Console.Error.WriteLine(ex != null
|
||||
? $"Connection lost: {ex.Message}"
|
||||
: "Connection closed.");
|
||||
}
|
||||
exitTcs.TrySetResult(cts.IsCancellationRequested ? 0 : 1);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
connection.Reconnecting += ex =>
|
||||
{
|
||||
Console.Error.WriteLine($"Reconnecting... ({ex?.Message})");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
connection.Reconnected += _ =>
|
||||
{
|
||||
Console.Error.WriteLine("Reconnected. Re-subscribing...");
|
||||
return connection.InvokeAsync("SubscribeInstance", instanceId);
|
||||
};
|
||||
|
||||
// Connect and subscribe
|
||||
try
|
||||
{
|
||||
await connection.StartAsync(cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// CLI-010: Ctrl+C during connect throws OperationCanceledException — that is
|
||||
// a graceful user cancellation, not a connection failure.
|
||||
var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested);
|
||||
if (failure.IsCancellation)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
return failure.ExitCode;
|
||||
}
|
||||
|
||||
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
||||
await connection.DisposeAsync();
|
||||
return failure.ExitCode;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await connection.InvokeAsync("SubscribeInstance", instanceId, cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OutputFormatter.WriteError($"Subscribe failed: {ex.Message}", "SUBSCRIBE_FAILED");
|
||||
await connection.DisposeAsync();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (isTable)
|
||||
{
|
||||
Console.WriteLine($"Connected to instance {instanceId}. Waiting for data...");
|
||||
}
|
||||
|
||||
// Wait for cancellation (Ctrl+C) or stream termination
|
||||
try
|
||||
{
|
||||
await exitTcs.Task.WaitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ctrl+C — graceful shutdown
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await connection.InvokeAsync("UnsubscribeInstance");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort
|
||||
}
|
||||
|
||||
await connection.DisposeAsync();
|
||||
|
||||
// CLI-012: resolve the exit code from a single authoritative source. A result
|
||||
// set by OnStreamTerminated/Closed always wins; a brief grace period covers a
|
||||
// termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0.
|
||||
return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task);
|
||||
}
|
||||
|
||||
private static void PrintSnapshotTable(JsonElement snapshot)
|
||||
{
|
||||
if (snapshot.TryGetProperty("attributeValues", out var attrs) && attrs.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
Console.WriteLine(" Attributes:");
|
||||
Console.WriteLine($" {"Name",-30} {"Value",-20} {"Quality",-10} Timestamp");
|
||||
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
|
||||
foreach (var av in attrs.EnumerateArray())
|
||||
{
|
||||
var name = av.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
|
||||
var value = av.TryGetProperty("value", out var v) ? v.ToString() : "?";
|
||||
var quality = av.TryGetProperty("quality", out var q) ? q.GetString() : "?";
|
||||
var ts = av.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
||||
Console.WriteLine($" {name,-30} {value,-20} {quality,-10} {ts}");
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot.TryGetProperty("alarmStates", out var alarms) && alarms.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
Console.WriteLine(" Alarms:");
|
||||
Console.WriteLine($" {"Name",-30} {"State",-20} {"Priority",-10} Timestamp");
|
||||
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
|
||||
foreach (var al in alarms.EnumerateArray())
|
||||
{
|
||||
var name = al.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
|
||||
var state = al.TryGetProperty("state", out var s) ? s.ToString() : "?";
|
||||
var priority = al.TryGetProperty("priority", out var p) ? p.ToString() : "?";
|
||||
var ts = al.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
||||
Console.WriteLine($" {name,-30} {state,-20} P{priority,-9} {ts}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, testable helpers for the <c>debug stream</c> command. The SignalR-driven
|
||||
/// <see cref="DebugCommands"/> body itself cannot be unit-tested without a live hub, so
|
||||
/// the decision logic — connect-failure classification (CLI-010) and exit-code
|
||||
/// resolution after stream termination (CLI-012) — is extracted here.
|
||||
/// </summary>
|
||||
internal static class DebugStreamHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// The maximum time <see cref="ResolveStreamExitCodeAsync"/> waits for an in-flight
|
||||
/// <c>TrySetResult</c> (from <c>OnStreamTerminated</c>/<c>Closed</c>) to land after
|
||||
/// the wait was cancelled by Ctrl+C, so a termination racing with cancellation is
|
||||
/// observed deterministically rather than depending on scheduling.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan ExitGracePeriod = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>Outcome of classifying an exception thrown while connecting.</summary>
|
||||
internal readonly record struct ConnectFailure(bool IsCancellation, int ExitCode);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies an exception thrown by <c>HubConnection.StartAsync</c>. A
|
||||
/// cancellation exception that coincides with a user-requested cancellation
|
||||
/// (Ctrl+C during connect) is a graceful shutdown — exit 0, no error printed.
|
||||
/// Anything else is a genuine connection failure — exit 1.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception thrown by HubConnection.StartAsync.</param>
|
||||
/// <param name="cancellationRequested">True when the user requested cancellation (Ctrl+C) before the exception was thrown.</param>
|
||||
internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested)
|
||||
{
|
||||
if (cancellationRequested && ex is OperationCanceledException)
|
||||
return new ConnectFailure(IsCancellation: true, ExitCode: 0);
|
||||
|
||||
return new ConnectFailure(IsCancellation: false, ExitCode: 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>debug stream</c> exit code from a single authoritative source —
|
||||
/// the <c>exitTcs</c> task. If a result was set by <c>OnStreamTerminated</c> or the
|
||||
/// <c>Closed</c> handler it is always preferred (even when Ctrl+C also fired);
|
||||
/// a brief grace period covers a termination that races with cancellation. If no
|
||||
/// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0.
|
||||
/// </summary>
|
||||
/// <param name="exitTask">The task whose result is the intended exit code, set by OnStreamTerminated or the Closed handler.</param>
|
||||
internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask)
|
||||
{
|
||||
if (exitTask.IsCompletedSuccessfully)
|
||||
return exitTask.Result;
|
||||
|
||||
var completed = await Task.WhenAny(exitTask, Task.Delay(ExitGracePeriod));
|
||||
if (ReferenceEquals(completed, exitTask) && exitTask.IsCompletedSuccessfully)
|
||||
return exitTask.Result;
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class DeployCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>deploy</c> command group with all sub-commands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global management URL option.</param>
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username option.</param>
|
||||
/// <param name="passwordOption">Global password option.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("deploy") { Description = "Deployment operations" };
|
||||
|
||||
command.Add(BuildInstance(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildArtifacts(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildStatus(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildInstance(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("instance") { Description = "Deploy a single instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildArtifacts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Target site ID (all sites if omitted)" };
|
||||
var cmd = new Command("artifacts") { Description = "Deploy artifacts to site(s)" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildStatus(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
||||
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
|
||||
var cmd = new Command("status") { Description = "Query deployment status" };
|
||||
cmd.Add(instanceIdOption);
|
||||
cmd.Add(statusOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var instanceId = result.GetValue(instanceIdOption);
|
||||
var status = result.GetValue(statusOption);
|
||||
var page = result.GetValue(pageOption);
|
||||
var pageSize = result.GetValue(pageSizeOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryDeploymentsCommand(instanceId, status, page, pageSize));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class ExternalSystemCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>external-system</c> CLI command group with subcommands for managing external systems.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global option for the management URL.</param>
|
||||
/// <param name="formatOption">Global option for the output format.</param>
|
||||
/// <param name="usernameOption">Global option for the authentication username.</param>
|
||||
/// <param name="passwordOption">Global option for the authentication password.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("external-system") { Description = "Manage external systems" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildMethodGroup(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get an external system by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetExternalSystemCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "System name", Required = true };
|
||||
var endpointUrlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", Required = true };
|
||||
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type", Required = true };
|
||||
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an external system" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(endpointUrlOption);
|
||||
cmd.Add(authTypeOption);
|
||||
cmd.Add(authConfigOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var endpointUrl = result.GetValue(endpointUrlOption)!;
|
||||
var authType = result.GetValue(authTypeOption)!;
|
||||
var authConfig = result.GetValue(authConfigOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateExternalSystemCommand(id, name, endpointUrl, authType, authConfig));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all external systems" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListExternalSystemsCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "System name", Required = true };
|
||||
var endpointUrlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", Required = true };
|
||||
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type (ApiKey, BasicAuth)", Required = true };
|
||||
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create an external system" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(endpointUrlOption);
|
||||
cmd.Add(authTypeOption);
|
||||
cmd.Add(authConfigOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var endpointUrl = result.GetValue(endpointUrlOption)!;
|
||||
var authType = result.GetValue(authTypeOption)!;
|
||||
var authConfig = result.GetValue(authConfigOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateExternalSystemCommand(name, endpointUrl, authType, authConfig));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete an external system" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteExternalSystemCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// -- Method subcommands --
|
||||
|
||||
private static Command BuildMethodGroup(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("method") { Description = "Manage external system methods" };
|
||||
group.Add(BuildMethodList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
group.Add(BuildMethodGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
group.Add(BuildMethodCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
group.Add(BuildMethodUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
group.Add(BuildMethodDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildMethodList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
|
||||
var cmd = new Command("list") { Description = "List methods for an external system" };
|
||||
cmd.Add(sysIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get an external system method by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
|
||||
var httpMethodOption = new Option<string>("--http-method") { Description = "HTTP method (GET, POST, PUT, DELETE)", Required = true };
|
||||
var pathOption = new Option<string>("--path") { Description = "URL path (e.g. /api/Add)", Required = true };
|
||||
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
|
||||
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create an external system method" };
|
||||
cmd.Add(sysIdOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(httpMethodOption);
|
||||
cmd.Add(pathOption);
|
||||
cmd.Add(paramsOption);
|
||||
cmd.Add(returnOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateExternalSystemMethodCommand(
|
||||
result.GetValue(sysIdOption),
|
||||
result.GetValue(nameOption)!,
|
||||
result.GetValue(httpMethodOption)!,
|
||||
result.GetValue(pathOption)!,
|
||||
result.GetValue(paramsOption),
|
||||
result.GetValue(returnOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var nameOption = new Option<string?>("--name") { Description = "Method name" };
|
||||
var httpMethodOption = new Option<string?>("--http-method") { Description = "HTTP method" };
|
||||
var pathOption = new Option<string?>("--path") { Description = "URL path" };
|
||||
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
|
||||
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an external system method" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(httpMethodOption);
|
||||
cmd.Add(pathOption);
|
||||
cmd.Add(paramsOption);
|
||||
cmd.Add(returnOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateExternalSystemMethodCommand(
|
||||
result.GetValue(idOption),
|
||||
result.GetValue(nameOption),
|
||||
result.GetValue(httpMethodOption),
|
||||
result.GetValue(pathOption),
|
||||
result.GetValue(paramsOption),
|
||||
result.GetValue(returnOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete an external system method" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class HealthCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>health</c> command group with summary, site, event-log, and parked-message sub-commands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
|
||||
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
|
||||
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
|
||||
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("health") { Description = "Health monitoring" };
|
||||
|
||||
command.Add(BuildSummary(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSite(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildEventLog(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildParkedMessages(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildSummary(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("summary") { Description = "Get system health summary" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetHealthSummaryCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildSite(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
|
||||
var cmd = new Command("site") { Description = "Get health for a specific site" };
|
||||
cmd.Add(identifierOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var identifier = result.GetValue(identifierOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetSiteHealthCommand(identifier));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildEventLog(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
|
||||
var eventTypeOption = new Option<string?>("--event-type") { Description = "Filter by event type" };
|
||||
var severityOption = new Option<string?>("--severity") { Description = "Filter by severity" };
|
||||
var keywordOption = new Option<string?>("--keyword") { Description = "Keyword search" };
|
||||
var fromOption = new Option<DateTimeOffset?>("--from") { Description = "Start date (ISO 8601)" };
|
||||
var toOption = new Option<DateTimeOffset?>("--to") { Description = "End date (ISO 8601)" };
|
||||
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
var instanceNameOption = new Option<string?>("--instance-name") { Description = "Filter by instance name" };
|
||||
|
||||
var cmd = new Command("event-log") { Description = "Query site event logs" };
|
||||
cmd.Add(siteOption);
|
||||
cmd.Add(eventTypeOption);
|
||||
cmd.Add(severityOption);
|
||||
cmd.Add(keywordOption);
|
||||
cmd.Add(fromOption);
|
||||
cmd.Add(toOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.Add(instanceNameOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryEventLogsCommand(
|
||||
result.GetValue(siteOption)!,
|
||||
result.GetValue(eventTypeOption),
|
||||
result.GetValue(severityOption),
|
||||
result.GetValue(keywordOption),
|
||||
result.GetValue(fromOption),
|
||||
result.GetValue(toOption),
|
||||
result.GetValue(pageOption),
|
||||
result.GetValue(pageSizeOption),
|
||||
result.GetValue(instanceNameOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildParkedMessages(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
|
||||
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
|
||||
var cmd = new Command("parked-messages") { Description = "Query parked messages at a site" };
|
||||
cmd.Add(siteOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryParkedMessagesCommand(
|
||||
result.GetValue(siteOption)!,
|
||||
result.GetValue(pageOption),
|
||||
result.GetValue(pageSizeOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class InstanceCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the instance command and its subcommands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">The URL option.</param>
|
||||
/// <param name="formatOption">The format option.</param>
|
||||
/// <param name="usernameOption">The username option.</param>
|
||||
/// <param name="passwordOption">The password option.</param>
|
||||
/// <returns>The instance command with all subcommands.</returns>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("instance") { Description = "Manage instances" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildEnable(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDisable(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get an instance by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetInstanceCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildSetBindings(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var bindingsOption = new Option<string>("--bindings") { Description = "JSON array of [attributeName, dataConnectionId] pairs", Required = true };
|
||||
|
||||
var cmd = new Command("set-bindings") { Description = "Set data connection bindings for an instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(bindingsOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var bindingsJson = result.GetValue(bindingsOption)!;
|
||||
if (!TryParseBindings(bindingsJson, out var bindings, out var error))
|
||||
{
|
||||
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetConnectionBindingsCommand(id, bindings!));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>--bindings</c> argument — a JSON array of
|
||||
/// <c>[attributeName, dataConnectionId]</c> pairs — into a typed list.
|
||||
/// Returns <c>false</c> with a descriptive <paramref name="error"/> instead of
|
||||
/// throwing when the JSON is malformed, a pair has the wrong arity, or an element
|
||||
/// has the wrong type.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string to parse.</param>
|
||||
/// <param name="bindings">The parsed bindings list, or null if parsing fails.</param>
|
||||
/// <param name="error">The error message if parsing fails, or null on success.</param>
|
||||
/// <returns>True if parsing succeeded; false otherwise.</returns>
|
||||
internal static bool TryParseBindings(
|
||||
string json,
|
||||
out List<ConnectionBinding>? bindings,
|
||||
out string? error)
|
||||
{
|
||||
bindings = null;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var pairs = System.Text.Json.JsonSerializer
|
||||
.Deserialize<List<List<System.Text.Json.JsonElement>>>(json);
|
||||
if (pairs == null)
|
||||
{
|
||||
error = "Bindings JSON must be a non-null array of [attributeName, dataConnectionId] pairs.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new List<ConnectionBinding>(pairs.Count);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
if (pair.Count != 2)
|
||||
{
|
||||
error = "Each binding must be a [attributeName, dataConnectionId] pair of exactly two elements.";
|
||||
return false;
|
||||
}
|
||||
if (pair[0].ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
error = "The first element of each binding (attributeName) must be a string.";
|
||||
return false;
|
||||
}
|
||||
if (pair[1].ValueKind != System.Text.Json.JsonValueKind.Number
|
||||
|| !pair[1].TryGetInt32(out var connectionId))
|
||||
{
|
||||
error = "The second element of each binding (dataConnectionId) must be an integer.";
|
||||
return false;
|
||||
}
|
||||
result.Add(new ConnectionBinding(pair[0].GetString()!, connectionId));
|
||||
}
|
||||
|
||||
bindings = result;
|
||||
return true;
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
error = $"Invalid bindings JSON: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>--overrides</c> argument — a JSON object of
|
||||
/// <c>attributeName -> value</c> pairs — into a typed dictionary. Returns
|
||||
/// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing
|
||||
/// when the JSON is malformed or null.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string to parse.</param>
|
||||
/// <param name="overrides">The parsed overrides dictionary, or null if parsing fails.</param>
|
||||
/// <param name="error">The error message if parsing fails, or null on success.</param>
|
||||
/// <returns>True if parsing succeeded; false otherwise.</returns>
|
||||
internal static bool TryParseOverrides(
|
||||
string json,
|
||||
out Dictionary<string, string?>? overrides,
|
||||
out string? error)
|
||||
{
|
||||
overrides = null;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var parsed = System.Text.Json.JsonSerializer
|
||||
.Deserialize<Dictionary<string, string?>>(json);
|
||||
if (parsed == null)
|
||||
{
|
||||
error = "Overrides JSON must be a non-null object of attribute name -> value pairs.";
|
||||
return false;
|
||||
}
|
||||
overrides = parsed;
|
||||
return true;
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
error = $"Invalid overrides JSON: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
||||
var templateIdOption = new Option<int?>("--template-id") { Description = "Filter by template ID" };
|
||||
var searchOption = new Option<string?>("--search") { Description = "Search term" };
|
||||
|
||||
var cmd = new Command("list") { Description = "List instances" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.Add(templateIdOption);
|
||||
cmd.Add(searchOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
var templateId = result.GetValue(templateIdOption);
|
||||
var search = result.GetValue(searchOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ListInstancesCommand(siteId, templateId, search));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Unique instance name", Required = true };
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
var areaIdOption = new Option<int?>("--area-id") { Description = "Area ID" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a new instance" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(templateIdOption);
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.Add(areaIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var templateId = result.GetValue(templateIdOption);
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
var areaId = result.GetValue(areaIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateInstanceCommand(name, templateId, siteId, areaId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDeploy(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("deploy") { Description = "Deploy an instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildEnable(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("enable") { Description = "Enable an instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtEnableInstanceCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDisable(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("disable") { Description = "Disable an instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDisableInstanceCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete an instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeleteInstanceCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildSetOverrides(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var overridesOption = new Option<string>("--overrides") { Description = "JSON object of attribute name -> value pairs, e.g. {\"Speed\": \"100\", \"Mode\": null}", Required = true };
|
||||
|
||||
var cmd = new Command("set-overrides") { Description = "Set attribute overrides for an instance" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(overridesOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var overridesJson = result.GetValue(overridesOption)!;
|
||||
if (!TryParseOverrides(overridesJson, out var overrides, out var error))
|
||||
{
|
||||
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetInstanceOverridesCommand(id, overrides!));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildAlarmOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" };
|
||||
|
||||
// set
|
||||
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var setAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true };
|
||||
var setConfigOption = new Option<string?>("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" };
|
||||
var setPriorityOption = new Option<int?>("--priority") { Description = "Priority override (0-1000)" };
|
||||
var setCmd = new Command("set") { Description = "Set (upsert) an alarm override on an instance" };
|
||||
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption);
|
||||
setCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetInstanceAlarmOverrideCommand(
|
||||
result.GetValue(setIdOption),
|
||||
result.GetValue(setAlarmOption)!,
|
||||
result.GetValue(setConfigOption),
|
||||
result.GetValue(setPriorityOption)));
|
||||
});
|
||||
group.Add(setCmd);
|
||||
|
||||
// delete
|
||||
var delIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var delAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name", Required = true };
|
||||
var delCmd = new Command("delete") { Description = "Remove an alarm override on an instance" };
|
||||
delCmd.Add(delIdOption); delCmd.Add(delAlarmOption);
|
||||
delCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteInstanceAlarmOverrideCommand(
|
||||
result.GetValue(delIdOption),
|
||||
result.GetValue(delAlarmOption)!));
|
||||
});
|
||||
group.Add(delCmd);
|
||||
|
||||
// list
|
||||
var listIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var listCmd = new Command("list") { Description = "List all alarm overrides for an instance" };
|
||||
listCmd.Add(listIdOption);
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ListInstanceAlarmOverridesCommand(result.GetValue(listIdOption)));
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildSetArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var areaIdOption = new Option<int?>("--area-id") { Description = "Area ID (omit to clear area assignment)" };
|
||||
|
||||
var cmd = new Command("set-area") { Description = "Reassign an instance to a different area" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(areaIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var areaId = result.GetValue(areaIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetInstanceAreaCommand(id, areaId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDiff(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
|
||||
var cmd = new Command("diff") { Description = "Show deployment diff (deployed vs current template)" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new GetDeploymentDiffCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class NotificationCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>notification</c> command group with sub-commands for managing notification lists and SMTP configuration.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
|
||||
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
|
||||
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
|
||||
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("notification") { Description = "Manage notification lists" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get a notification list by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetNotificationListCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "List name", Required = true };
|
||||
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update a notification list" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(emailsOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var emailsRaw = result.GetValue(emailsOption)!;
|
||||
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateNotificationListCommand(id, name, emails));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildSmtp(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
|
||||
|
||||
var listCmd = new Command("list") { Description = "List SMTP configurations" };
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmtpConfigsCommand());
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
|
||||
updateCmd.Add(SmtpIdOption);
|
||||
updateCmd.Add(SmtpServerOption);
|
||||
updateCmd.Add(SmtpPortOption);
|
||||
updateCmd.Add(SmtpAuthModeOption);
|
||||
updateCmd.Add(SmtpFromOption);
|
||||
updateCmd.Add(SmtpTlsModeOption);
|
||||
updateCmd.Add(SmtpCredentialsOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
BuildUpdateSmtpConfigCommand(result));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
// SMTP update options are static so the parsed values can be read back both
|
||||
// from the SetAction and from BuildUpdateSmtpConfigCommand (used by tests).
|
||||
private static readonly Option<int> SmtpIdOption =
|
||||
new("--id") { Description = "SMTP config ID", Required = true };
|
||||
private static readonly Option<string> SmtpServerOption =
|
||||
new("--server") { Description = "SMTP server", Required = true };
|
||||
private static readonly Option<int> SmtpPortOption =
|
||||
new("--port") { Description = "SMTP port", Required = true };
|
||||
private static readonly Option<string> SmtpAuthModeOption =
|
||||
new("--auth-mode") { Description = "Auth mode", Required = true };
|
||||
private static readonly Option<string> SmtpFromOption =
|
||||
new("--from-address") { Description = "From email address", Required = true };
|
||||
private static readonly Option<string?> SmtpTlsModeOption = CreateTlsModeOption();
|
||||
private static readonly Option<string?> SmtpCredentialsOption =
|
||||
new("--credentials")
|
||||
{
|
||||
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
|
||||
"for OAuth2 (optional; preserves existing if omitted)",
|
||||
};
|
||||
|
||||
private static Option<string?> CreateTlsModeOption()
|
||||
{
|
||||
var option = new Option<string?>("--tls-mode")
|
||||
{
|
||||
Description = "TLS mode: None, StartTLS, or SSL (optional; preserves existing if omitted)",
|
||||
};
|
||||
option.AcceptOnlyFromAmong("None", "StartTLS", "SSL");
|
||||
return option;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
|
||||
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
|
||||
/// null when omitted so the server-side handler preserves the existing values.
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
|
||||
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
|
||||
{
|
||||
var id = result.GetValue(SmtpIdOption);
|
||||
var server = result.GetValue(SmtpServerOption)!;
|
||||
var port = result.GetValue(SmtpPortOption);
|
||||
var authMode = result.GetValue(SmtpAuthModeOption)!;
|
||||
var from = result.GetValue(SmtpFromOption)!;
|
||||
var tlsMode = result.GetValue(SmtpTlsModeOption);
|
||||
var credentials = result.GetValue(SmtpCredentialsOption);
|
||||
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all notification lists" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListNotificationListsCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Notification list name", Required = true };
|
||||
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a notification list" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(emailsOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var emailsRaw = result.GetValue(emailsOption)!;
|
||||
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateNotificationListCommand(name, emails));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete a notification list" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteNotificationListCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class SecurityCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>security</c> command group with API key, role mapping, and scope rule subcommands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Shared management URL option.</param>
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username option for authentication.</param>
|
||||
/// <param name="passwordOption">Shared password option for authentication.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("security") { Description = "Manage security settings" };
|
||||
|
||||
command.Add(BuildApiKey(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildRoleMapping(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildScopeRule(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildApiKey(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("api-key") { Description = "Manage API keys" };
|
||||
|
||||
var listCmd = new Command("list") { Description = "List all API keys" };
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListApiKeysCommand());
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true };
|
||||
var createCmd = new Command("create") { Description = "Create an API key" };
|
||||
createCmd.Add(nameOption);
|
||||
createCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name));
|
||||
});
|
||||
group.Add(createCmd);
|
||||
|
||||
var idOption = new Option<int>("--id") { Description = "API key ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete an API key" };
|
||||
deleteCmd.Add(idOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "API key ID", Required = true };
|
||||
var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
|
||||
var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(enabledOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(updateIdOption);
|
||||
var enabled = result.GetValue(enabledOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildRoleMapping(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
|
||||
|
||||
var listCmd = new Command("list") { Description = "List all role mappings" };
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListRoleMappingsCommand());
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
var ldapGroupOption = new Option<string>("--ldap-group") { Description = "LDAP group name", Required = true };
|
||||
var roleOption = new Option<string>("--role") { Description = "Role name", Required = true };
|
||||
var createCmd = new Command("create") { Description = "Create a role mapping" };
|
||||
createCmd.Add(ldapGroupOption);
|
||||
createCmd.Add(roleOption);
|
||||
createCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var ldapGroup = result.GetValue(ldapGroupOption)!;
|
||||
var role = result.GetValue(roleOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateRoleMappingCommand(ldapGroup, role));
|
||||
});
|
||||
group.Add(createCmd);
|
||||
|
||||
var idOption = new Option<int>("--id") { Description = "Mapping ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a role mapping" };
|
||||
deleteCmd.Add(idOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteRoleMappingCommand(id));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Mapping ID", Required = true };
|
||||
var updateLdapGroupOption = new Option<string>("--ldap-group") { Description = "LDAP group name", Required = true };
|
||||
var updateRoleOption = new Option<string>("--role") { Description = "Role name", Required = true };
|
||||
var updateCmd = new Command("update") { Description = "Update a role mapping" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateLdapGroupOption);
|
||||
updateCmd.Add(updateRoleOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(updateIdOption);
|
||||
var ldapGroup = result.GetValue(updateLdapGroupOption)!;
|
||||
var role = result.GetValue(updateRoleOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateRoleMappingCommand(id, ldapGroup, role));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildScopeRule(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("scope-rule") { Description = "Manage LDAP scope rules" };
|
||||
|
||||
var mappingIdOption = new Option<int>("--mapping-id") { Description = "Role mapping ID", Required = true };
|
||||
var listCmd = new Command("list") { Description = "List scope rules for a mapping" };
|
||||
listCmd.Add(mappingIdOption);
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var mappingId = result.GetValue(mappingIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListScopeRulesCommand(mappingId));
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
var addMappingIdOption = new Option<int>("--mapping-id") { Description = "Role mapping ID", Required = true };
|
||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
var addCmd = new Command("add") { Description = "Add a scope rule" };
|
||||
addCmd.Add(addMappingIdOption);
|
||||
addCmd.Add(siteIdOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var mappingId = result.GetValue(addMappingIdOption);
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new AddScopeRuleCommand(mappingId, siteId));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Scope rule ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a scope rule" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(deleteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteScopeRuleCommand(id));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class SharedScriptCommands
|
||||
{
|
||||
/// <summary>Builds the <c>shared-script</c> command group with list, get, create, update, and delete sub-commands.</summary>
|
||||
/// <param name="urlOption">Shared management URL option.</param>
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username option.</param>
|
||||
/// <param name="passwordOption">Shared password option.</param>
|
||||
/// <returns>The configured <see cref="Command"/> for the shared-script group.</returns>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("shared-script") { Description = "Manage shared scripts" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all shared scripts" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListSharedScriptsCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get a shared script by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetSharedScriptCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
|
||||
var codeOption = new Option<string>("--code") { Description = "Script code", Required = true };
|
||||
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a shared script" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(codeOption);
|
||||
cmd.Add(parametersOption);
|
||||
cmd.Add(returnDefOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var code = result.GetValue(codeOption)!;
|
||||
var parameters = result.GetValue(parametersOption);
|
||||
var returnDef = result.GetValue(returnDefOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateSharedScriptCommand(name, code, parameters, returnDef));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
|
||||
var codeOption = new Option<string>("--code") { Description = "Script code", Required = true };
|
||||
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update a shared script" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(codeOption);
|
||||
cmd.Add(parametersOption);
|
||||
cmd.Add(returnDefOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var code = result.GetValue(codeOption)!;
|
||||
var parameters = result.GetValue(parametersOption);
|
||||
var returnDef = result.GetValue(returnDefOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateSharedScriptCommand(id, name, code, parameters, returnDef));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete a shared script" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteSharedScriptCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class SiteCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>site</c> command group and all its subcommands.
|
||||
/// </summary>
|
||||
/// <param name="urlOption">Global management URL option.</param>
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username option.</param>
|
||||
/// <param name="passwordOption">Global password option.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("site") { Description = "Manage sites" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDeployArtifacts(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildArea(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get a site by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetSiteCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all sites" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListSitesCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Site name", Required = true };
|
||||
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
|
||||
var descOption = new Option<string?>("--description") { Description = "Site description" };
|
||||
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
|
||||
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
|
||||
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
|
||||
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a new site" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(identifierOption);
|
||||
cmd.Add(descOption);
|
||||
cmd.Add(nodeAOption);
|
||||
cmd.Add(nodeBOption);
|
||||
cmd.Add(grpcNodeAOption);
|
||||
cmd.Add(grpcNodeBOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var identifier = result.GetValue(identifierOption)!;
|
||||
var desc = result.GetValue(descOption);
|
||||
var nodeA = result.GetValue(nodeAOption);
|
||||
var nodeB = result.GetValue(nodeBOption);
|
||||
var grpcNodeA = result.GetValue(grpcNodeAOption);
|
||||
var grpcNodeB = result.GetValue(grpcNodeBOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Site name", Required = true };
|
||||
var descOption = new Option<string?>("--description") { Description = "Site description" };
|
||||
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
|
||||
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
|
||||
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
|
||||
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an existing site" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(descOption);
|
||||
cmd.Add(nodeAOption);
|
||||
cmd.Add(nodeBOption);
|
||||
cmd.Add(grpcNodeAOption);
|
||||
cmd.Add(grpcNodeBOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var desc = result.GetValue(descOption);
|
||||
var nodeA = result.GetValue(nodeAOption);
|
||||
var nodeB = result.GetValue(nodeBOption);
|
||||
var grpcNodeA = result.GetValue(grpcNodeAOption);
|
||||
var grpcNodeB = result.GetValue(grpcNodeBOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateSiteCommand(id, name, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete a site" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteSiteCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("area") { Description = "Manage areas" };
|
||||
|
||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
var listCmd = new Command("list") { Description = "List areas for a site" };
|
||||
listCmd.Add(siteIdOption);
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListAreasCommand(siteId));
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
var createSiteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Area name", Required = true };
|
||||
var parentOption = new Option<int?>("--parent-id") { Description = "Parent area ID" };
|
||||
var createCmd = new Command("create") { Description = "Create an area" };
|
||||
createCmd.Add(createSiteIdOption);
|
||||
createCmd.Add(nameOption);
|
||||
createCmd.Add(parentOption);
|
||||
createCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(createSiteIdOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var parentId = result.GetValue(parentOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateAreaCommand(siteId, name, parentId));
|
||||
});
|
||||
group.Add(createCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
|
||||
var updateNameOption = new Option<string>("--name") { Description = "New area name", Required = true };
|
||||
var updateCmd = new Command("update") { Description = "Update an area" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateNameOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(updateIdOption);
|
||||
var name = result.GetValue(updateNameOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateAreaCommand(id, name));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete an area" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(deleteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteAreaCommand(id));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildDeployArtifacts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Target site ID (all sites if omitted)" };
|
||||
var cmd = new Command("deploy-artifacts") { Description = "Deploy artifacts to site(s)" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable table formatter for <c>audit query --format table</c> (Audit Log
|
||||
/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed
|
||||
/// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are
|
||||
/// truncated with an ellipsis so columns stay aligned regardless of payload size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A header row is emitted once per page (matching the streamable, page-at-a-time
|
||||
/// contract of <see cref="IAuditFormatter"/>). An empty page emits the header only,
|
||||
/// so the column shape is visible even with zero results.
|
||||
/// </remarks>
|
||||
public sealed class TableAuditFormatter : IAuditFormatter
|
||||
{
|
||||
/// <summary>JSON property name (camelCase, as the server serializes it) → column header.</summary>
|
||||
private static readonly (string Property, string Header, int MaxWidth)[] Columns =
|
||||
{
|
||||
("occurredAtUtc", "OccurredAtUtc", 24),
|
||||
("channel", "Channel", 14),
|
||||
("kind", "Kind", 18),
|
||||
("status", "Status", 12),
|
||||
("target", "Target", 32),
|
||||
("actor", "Actor", 20),
|
||||
("durationMs", "DurationMs", 10),
|
||||
("httpStatus", "HttpStatus", 10),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
|
||||
{
|
||||
// Build every cell first so column widths account for the actual data.
|
||||
var rows = new List<string[]>(events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var cells = new string[Columns.Length];
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth);
|
||||
rows.Add(cells);
|
||||
}
|
||||
|
||||
var widths = new int[Columns.Length];
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
widths[i] = Columns[i].Header.Length;
|
||||
foreach (var row in rows)
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
widths[i] = Math.Max(widths[i], row[i].Length);
|
||||
|
||||
WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths);
|
||||
foreach (var row in rows)
|
||||
WriteRow(output, row, widths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a cell value for <paramref name="property"/> from an audit event.
|
||||
/// A missing property or a JSON <c>null</c> renders as an empty string (never
|
||||
/// the literal text "null").
|
||||
/// </summary>
|
||||
private static string CellValue(JsonElement evt, string property)
|
||||
{
|
||||
if (evt.ValueKind != JsonValueKind.Object
|
||||
|| !evt.TryGetProperty(property, out var value)
|
||||
|| value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() ?? string.Empty
|
||||
: value.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates <paramref name="value"/> to <paramref name="maxWidth"/> characters,
|
||||
/// replacing the tail with a single-character ellipsis so the column stays aligned.
|
||||
/// </summary>
|
||||
private static string Truncate(string value, int maxWidth)
|
||||
{
|
||||
if (maxWidth <= 0 || value.Length <= maxWidth)
|
||||
return value;
|
||||
if (maxWidth == 1)
|
||||
return "…";
|
||||
return value.Substring(0, maxWidth - 1) + "…";
|
||||
}
|
||||
|
||||
private static void WriteRow(TextWriter output, IReadOnlyList<string> cells, int[] widths)
|
||||
{
|
||||
for (var i = 0; i < cells.Count; i++)
|
||||
{
|
||||
// Last column is not padded — avoids trailing whitespace at line end.
|
||||
output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2));
|
||||
}
|
||||
output.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
public static class TemplateCommands
|
||||
{
|
||||
/// <summary>Builds the <c>template</c> command with its subcommands using the given shared CLI options.</summary>
|
||||
/// <param name="urlOption">Shared management URL option.</param>
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username option for authentication.</param>
|
||||
/// <param name="passwordOption">Shared password option for authentication.</param>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("template") { Description = "Manage templates" };
|
||||
|
||||
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildValidate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAttribute(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAlarm(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all templates" };
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get a template by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
|
||||
var descOption = new Option<string?>("--description") { Description = "Template description" };
|
||||
var parentOption = new Option<int?>("--parent-id") { Description = "Parent template ID" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a new template" };
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(descOption);
|
||||
cmd.Add(parentOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var desc = result.GetValue(descOption);
|
||||
var parentId = result.GetValue(parentOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateTemplateCommand(name, desc, parentId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
|
||||
var descOption = new Option<string?>("--description") { Description = "Template description" };
|
||||
var parentOption = new Option<int?>("--parent-id") { Description = "Parent template ID" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update a template" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(descOption);
|
||||
cmd.Add(parentOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var desc = result.GetValue(descOption);
|
||||
var parentId = result.GetValue(parentOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateTemplateCommand(id, name, desc, parentId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildValidate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||
var cmd = new Command("validate") { Description = "Validate a template" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ValidateTemplateCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete a template" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildAttribute(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("attribute") { Description = "Manage template attributes" };
|
||||
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
|
||||
var dataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
|
||||
var valueOption = new Option<string?>("--value") { Description = "Default value" };
|
||||
var descOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
|
||||
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
lockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var addCmd = new Command("add") { Description = "Add an attribute to a template" };
|
||||
addCmd.Add(templateIdOption);
|
||||
addCmd.Add(nameOption);
|
||||
addCmd.Add(dataTypeOption);
|
||||
addCmd.Add(valueOption);
|
||||
addCmd.Add(descOption);
|
||||
addCmd.Add(sourceOption);
|
||||
addCmd.Add(lockedOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new AddTemplateAttributeCommand(
|
||||
result.GetValue(templateIdOption),
|
||||
result.GetValue(nameOption)!,
|
||||
result.GetValue(dataTypeOption)!,
|
||||
result.GetValue(valueOption),
|
||||
result.GetValue(descOption),
|
||||
result.GetValue(sourceOption),
|
||||
result.GetValue(lockedOption)));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
|
||||
var updateNameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
|
||||
var updateDataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
|
||||
var updateValueOption = new Option<string?>("--value") { Description = "Default value" };
|
||||
var updateDescOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
|
||||
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
updateLockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var updateCmd = new Command("update") { Description = "Update a template attribute" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateNameOption);
|
||||
updateCmd.Add(updateDataTypeOption);
|
||||
updateCmd.Add(updateValueOption);
|
||||
updateCmd.Add(updateDescOption);
|
||||
updateCmd.Add(updateSourceOption);
|
||||
updateCmd.Add(updateLockedOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateTemplateAttributeCommand(
|
||||
result.GetValue(updateIdOption),
|
||||
result.GetValue(updateNameOption)!,
|
||||
result.GetValue(updateDataTypeOption)!,
|
||||
result.GetValue(updateValueOption),
|
||||
result.GetValue(updateDescOption),
|
||||
result.GetValue(updateSourceOption),
|
||||
result.GetValue(updateLockedOption)));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a template attribute" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteTemplateAttributeCommand(result.GetValue(deleteIdOption)));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildAlarm(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("alarm") { Description = "Manage template alarms" };
|
||||
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Alarm name", Required = true };
|
||||
var triggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
|
||||
var priorityOption = new Option<int>("--priority") { Description = "Alarm priority", Required = true };
|
||||
var descOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var triggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
|
||||
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
lockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var addCmd = new Command("add") { Description = "Add an alarm to a template" };
|
||||
addCmd.Add(templateIdOption);
|
||||
addCmd.Add(nameOption);
|
||||
addCmd.Add(triggerTypeOption);
|
||||
addCmd.Add(priorityOption);
|
||||
addCmd.Add(descOption);
|
||||
addCmd.Add(triggerConfigOption);
|
||||
addCmd.Add(lockedOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new AddTemplateAlarmCommand(
|
||||
result.GetValue(templateIdOption),
|
||||
result.GetValue(nameOption)!,
|
||||
result.GetValue(triggerTypeOption)!,
|
||||
result.GetValue(priorityOption)!,
|
||||
result.GetValue(descOption),
|
||||
result.GetValue(triggerConfigOption),
|
||||
result.GetValue(lockedOption)));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Alarm ID", Required = true };
|
||||
var updateNameOption = new Option<string>("--name") { Description = "Alarm name", Required = true };
|
||||
var updateTriggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
|
||||
var updatePriorityOption = new Option<int>("--priority") { Description = "Alarm priority", Required = true };
|
||||
var updateDescOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
|
||||
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
updateLockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var updateCmd = new Command("update") { Description = "Update a template alarm" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateNameOption);
|
||||
updateCmd.Add(updateTriggerTypeOption);
|
||||
updateCmd.Add(updatePriorityOption);
|
||||
updateCmd.Add(updateDescOption);
|
||||
updateCmd.Add(updateTriggerConfigOption);
|
||||
updateCmd.Add(updateLockedOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateTemplateAlarmCommand(
|
||||
result.GetValue(updateIdOption),
|
||||
result.GetValue(updateNameOption)!,
|
||||
result.GetValue(updateTriggerTypeOption)!,
|
||||
result.GetValue(updatePriorityOption)!,
|
||||
result.GetValue(updateDescOption),
|
||||
result.GetValue(updateTriggerConfigOption),
|
||||
result.GetValue(updateLockedOption)));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Alarm ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a template alarm" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteTemplateAlarmCommand(result.GetValue(deleteIdOption)));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildScript(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("script") { Description = "Manage template scripts" };
|
||||
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
|
||||
var codeOption = new Option<string>("--code") { Description = "Script code", Required = true };
|
||||
var triggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
|
||||
var triggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
|
||||
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
lockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var paramsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
|
||||
|
||||
var addCmd = new Command("add") { Description = "Add a script to a template" };
|
||||
addCmd.Add(templateIdOption);
|
||||
addCmd.Add(nameOption);
|
||||
addCmd.Add(codeOption);
|
||||
addCmd.Add(triggerTypeOption);
|
||||
addCmd.Add(triggerConfigOption);
|
||||
addCmd.Add(lockedOption);
|
||||
addCmd.Add(paramsOption);
|
||||
addCmd.Add(returnOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new AddTemplateScriptCommand(
|
||||
result.GetValue(templateIdOption),
|
||||
result.GetValue(nameOption)!,
|
||||
result.GetValue(codeOption)!,
|
||||
result.GetValue(triggerTypeOption)!,
|
||||
result.GetValue(triggerConfigOption),
|
||||
result.GetValue(lockedOption),
|
||||
result.GetValue(paramsOption),
|
||||
result.GetValue(returnOption)));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Script ID", Required = true };
|
||||
var updateNameOption = new Option<string>("--name") { Description = "Script name", Required = true };
|
||||
var updateCodeOption = new Option<string>("--code") { Description = "Script code", Required = true };
|
||||
var updateTriggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
|
||||
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
|
||||
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
updateLockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var updateParamsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
|
||||
|
||||
var updateCmd = new Command("update") { Description = "Update a template script" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateNameOption);
|
||||
updateCmd.Add(updateCodeOption);
|
||||
updateCmd.Add(updateTriggerTypeOption);
|
||||
updateCmd.Add(updateTriggerConfigOption);
|
||||
updateCmd.Add(updateLockedOption);
|
||||
updateCmd.Add(updateParamsOption);
|
||||
updateCmd.Add(updateReturnOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateTemplateScriptCommand(
|
||||
result.GetValue(updateIdOption),
|
||||
result.GetValue(updateNameOption)!,
|
||||
result.GetValue(updateCodeOption)!,
|
||||
result.GetValue(updateTriggerTypeOption)!,
|
||||
result.GetValue(updateTriggerConfigOption),
|
||||
result.GetValue(updateLockedOption),
|
||||
result.GetValue(updateParamsOption),
|
||||
result.GetValue(updateReturnOption)));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Script ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a template script" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteTemplateScriptCommand(result.GetValue(deleteIdOption)));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildComposition(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("composition") { Description = "Manage template compositions" };
|
||||
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var instanceNameOption = new Option<string>("--instance-name") { Description = "Composed instance name", Required = true };
|
||||
var composedTemplateIdOption = new Option<int>("--composed-template-id") { Description = "Composed template ID", Required = true };
|
||||
|
||||
var addCmd = new Command("add") { Description = "Add a composition to a template" };
|
||||
addCmd.Add(templateIdOption);
|
||||
addCmd.Add(instanceNameOption);
|
||||
addCmd.Add(composedTemplateIdOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new AddTemplateCompositionCommand(
|
||||
result.GetValue(templateIdOption),
|
||||
result.GetValue(instanceNameOption)!,
|
||||
result.GetValue(composedTemplateIdOption)));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Composition ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a template composition" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteTemplateCompositionCommand(result.GetValue(deleteIdOption)));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManagementHttpClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="baseUrl">The base URL for the management API.</param>
|
||||
/// <param name="username">The username for HTTP Basic authentication.</param>
|
||||
/// <param name="password">The password for HTTP Basic authentication.</param>
|
||||
public ManagementHttpClient(string baseUrl, string username, string password)
|
||||
: this(new HttpClient(), baseUrl, username, password)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
|
||||
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
|
||||
/// be exercised without a live server.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client to use for requests.</param>
|
||||
/// <param name="baseUrl">The base URL for the management API.</param>
|
||||
/// <param name="username">The username for HTTP Basic authentication.</param>
|
||||
/// <param name="password">The password for HTTP Basic authentication.</param>
|
||||
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a management command to the management API.
|
||||
/// </summary>
|
||||
/// <param name="commandName">The command name to execute.</param>
|
||||
/// <param name="payload">The command payload.</param>
|
||||
/// <param name="timeout">The request timeout.</param>
|
||||
/// <returns>A management response containing status and data.</returns>
|
||||
public async Task<ManagementResponse> SendCommandAsync(string commandName, object payload, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
|
||||
var body = JsonSerializer.Serialize(new { command = commandName, payload },
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.PostAsync("management", 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);
|
||||
}
|
||||
|
||||
// Parse error response
|
||||
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> against a REST endpoint (e.g. the audit
|
||||
/// <c>/api/audit/query</c> endpoint introduced by Audit Log #23 M8) and returns the
|
||||
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
|
||||
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
|
||||
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
||||
/// <param name="timeout">The request timeout.</param>
|
||||
/// <returns>A management response containing status and data.</returns>
|
||||
public async Task<ManagementResponse> SendGetAsync(string relativePath, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.GetAsync(relativePath, 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
|
||||
/// by <c>audit export</c>, where the response can be many megabytes. The caller owns
|
||||
/// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/>
|
||||
/// option ensures the body is not pre-buffered.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The raw HTTP response message for streaming.</returns>
|
||||
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
|
||||
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the underlying HTTP client.
|
||||
/// </summary>
|
||||
public void Dispose() => _httpClient.Dispose();
|
||||
}
|
||||
|
||||
public record ManagementResponse(int StatusCode, string? JsonData, string? Error, string? ErrorCode);
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
public static class OutputFormatter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>Serializes <paramref name="data"/> to indented JSON and writes it to standard output.</summary>
|
||||
/// <param name="data">The object to serialize; <c>null</c> is serialized as JSON <c>null</c>.</param>
|
||||
public static void WriteJson(object? data)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(data, JsonOptions));
|
||||
}
|
||||
|
||||
/// <summary>Writes a JSON error envelope with the given message and code to standard error.</summary>
|
||||
/// <param name="message">Human-readable error description.</param>
|
||||
/// <param name="code">Machine-readable error code.</param>
|
||||
public static void WriteError(string message, string code)
|
||||
{
|
||||
Console.Error.WriteLine(JsonSerializer.Serialize(new { error = message, code }, JsonOptions));
|
||||
}
|
||||
|
||||
/// <summary>Writes a plain-text padded table to standard output with the given column headers and data rows.</summary>
|
||||
/// <param name="rows">Data rows; each inner array corresponds to a column in the same order as <paramref name="headers"/>.</param>
|
||||
/// <param name="headers">Column header labels.</param>
|
||||
public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
|
||||
{
|
||||
var allRows = new List<string[]> { headers };
|
||||
allRows.AddRange(rows);
|
||||
var widths = new int[headers.Length];
|
||||
foreach (var row in allRows)
|
||||
for (int i = 0; i < Math.Min(row.Length, widths.Length); i++)
|
||||
widths[i] = Math.Max(widths[i], (row[i] ?? "").Length);
|
||||
|
||||
foreach (var row in allRows)
|
||||
{
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2));
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
var rootCommand = new RootCommand("ScadaBridge CLI — manage the ScadaBridge SCADA system");
|
||||
|
||||
var urlOption = new Option<string>("--url") { Description = "Management API URL", Recursive = true };
|
||||
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
|
||||
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
|
||||
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
|
||||
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
|
||||
// CliOptions.CreateFormatOption also constrains the accepted values (json/table).
|
||||
var formatOption = CliOptions.CreateFormatOption();
|
||||
|
||||
rootCommand.Add(urlOption);
|
||||
rootCommand.Add(usernameOption);
|
||||
rootCommand.Add(passwordOption);
|
||||
rootCommand.Add(formatOption);
|
||||
|
||||
// Register command groups
|
||||
rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
rootCommand.SetAction(_ =>
|
||||
{
|
||||
Console.WriteLine("Use --help to see available commands.");
|
||||
});
|
||||
|
||||
// Deprecation notice for the pre-M8 `audit-log` command name. The command itself
|
||||
// still works (it is an alias of `audit-config`), but using the old name emits a
|
||||
// warning to stderr so scripts can be migrated.
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error);
|
||||
|
||||
var parseResult = CommandLineParser.Parse(rootCommand, args);
|
||||
return await parseResult.InvokeAsync();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AssemblyName>scadabridge</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.CLI.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user