Complete management API and CLI command parity

This commit is contained in:
Joseph Doherty
2026-09-10 00:51:45 -04:00
parent 477e2abe97
commit 2750bd2809
38 changed files with 1637 additions and 199 deletions
@@ -0,0 +1,60 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>Constructs the production CLI command tree for execution and surface verification.</summary>
public static class CliApplication
{
/// <summary>Builds an independent root command with every supported operation.</summary>
/// <returns>The configured CLI root.</returns>
public static RootCommand BuildRoot()
{
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));
var dataConnectionCommand = DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption);
// Attach the site-routed browse/search/verify-endpoint/certs subcommands (actor parity, arch-review C4).
ConnectionBrowseCommands.AddTo(dataConnectionCommand, urlOption, formatOption, usernameOption, passwordOption);
rootCommand.Add(dataConnectionCommand);
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(SharedSchemaCommands.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.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
});
return rootCommand;
}
}
@@ -18,9 +18,9 @@ internal static class CommandHelpers
/// <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.
/// Optional per-command HTTP timeout. Deploy operations defer to the server-owned
/// deadline; ordinary commands default to 30 seconds. An explicit timeout
/// overrides this policy (for example, bundle and Flow Debugger requests).
/// </param>
/// <param name="onSuccess">
/// Optional success handler. When supplied, the helper invokes it with the success
@@ -90,7 +90,12 @@ internal static class CommandHelpers
// Send via HTTP
using var client = new ManagementHttpClient(url, username, password);
var response = await client.SendCommandAsync(commandName, command, timeout ?? TimeSpan.FromSeconds(30));
// Every alias of a deploy contract shares the same policy. The server bounds
// the operation; a shorter client deadline can report failure while it applies.
var requestTimeout = timeout ?? (command is MgmtDeployInstanceCommand or MgmtDeployArtifactsCommand or MgmtDeploySiteCommand
? Timeout.InfiniteTimeSpan
: TimeSpan.FromSeconds(30));
var response = await client.SendCommandAsync(commandName, command, requestTimeout);
// Caller-supplied success handler short-circuits the default rendering — but
// the error path still routes through IsAuthorizationFailure so the documented
@@ -17,6 +17,7 @@ public static class DataConnectionCommands
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(BuildMove(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
@@ -27,6 +28,22 @@ public static class DataConnectionCommands
return command;
}
private static Command BuildMove(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 siteOption = new Option<int>("--site-id") { Description = "Destination site ID", Required = true };
var cmd = new Command("move") { Description = "Move a data connection to another site" };
cmd.Add(idOption);
cmd.Add(siteOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new MoveDataConnectionCommand(result.GetValue(idOption), result.GetValue(siteOption)));
});
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 = "Data connection ID", Required = true };
@@ -28,6 +28,7 @@ public static class InstanceCommands
command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildNativeAlarmSourceOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAttributeOverride(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));
@@ -596,6 +597,43 @@ public static class InstanceCommands
return group;
}
private static Command BuildDeleteAttributeOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name (for example Module.Value)", Required = true };
var cmd = new Command("delete") { Description = "Remove one attribute override and restore the inherited value" };
cmd.Add(idOption);
cmd.Add(attributeOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteInstanceAttributeOverrideCommand(result.GetValue(idOption), result.GetValue(attributeOption)!));
});
return cmd;
}
private static Command BuildAttributeOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("attribute-override") { Description = "Manage individual instance attribute overrides" };
group.Add(BuildDeleteAttributeOverride(urlOption, formatOption, usernameOption, passwordOption));
return group;
}
private static Command BuildListNativeAlarmSourceOverrides(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var cmd = new Command("list") { Description = "List native alarm source overrides for an instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListInstanceNativeAlarmSourceOverridesCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildNativeAlarmSourceOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("native-alarm-source")
@@ -603,6 +641,8 @@ public static class InstanceCommands
Description = "Manage per-instance native alarm source overrides (retarget an inherited binding; blank = inherited)"
};
group.Add(BuildListNativeAlarmSourceOverrides(urlOption, formatOption, usernameOption, passwordOption));
// set
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var setSourceOption = new Option<string>("--source") { Description = "Source binding canonical name (e.g. 'Pressure' or 'Module.Pressure')", Required = true };
@@ -23,6 +23,8 @@ public static class NotificationCommands
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRename(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRecipient(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption));
@@ -30,6 +32,49 @@ public static class NotificationCommands
return command;
}
private static Option<int> RequiredId(string name, string description)
{
var option = new Option<int>(name) { Description = description, Required = true };
option.Validators.Add(result => { if (result.GetValueOrDefault<int>() <= 0) result.AddError($"{name} must be positive."); });
return option;
}
private static Command BuildRename(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("rename") { Description = "Rename a list, preserving all recipients and their IDs" };
var id = RequiredId("--id", "Notification list ID");
var name = new Option<string>("--name") { Required = true, Description = "New list name" };
cmd.Add(id); cmd.Add(name);
cmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new RenameNotificationListCommand(result.GetValue(id), result.GetValue(name)!)));
return cmd;
}
private static Command BuildRecipient(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("recipient") { Description = "Manage individual named recipients without replacing the list" };
var add = new Command("add") { Description = "Add a named recipient (address must match the list delivery channel)" };
var listId = RequiredId("--list-id", "Notification list ID");
var name = new Option<string>("--name") { Required = true, Description = "Recipient name" };
var email = new Option<string?>("--email") { Description = "Email address" };
var phone = new Option<string?>("--phone") { Description = "Phone number in E.164 format" };
add.Add(listId); add.Add(name); add.Add(email); add.Add(phone);
add.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(email)) && string.IsNullOrWhiteSpace(result.GetValue(phone)))
result.AddError("Provide --email and/or --phone for the recipient.");
});
add.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new AddNotificationRecipientCommand(result.GetValue(listId), result.GetValue(name)!, result.GetValue(email), result.GetValue(phone))));
var delete = new Command("delete") { Description = "Delete one recipient by its recipient ID" };
var id = RequiredId("--id", "Recipient ID");
delete.Add(id);
delete.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteNotificationRecipientCommand(result.GetValue(id))));
group.Add(add); group.Add(delete);
return group;
}
// ------------------------------------------------------------------------
// Notification list create/update options (static so the parsed values can
// be read back both from the SetAction and from the testable Build* helpers).
@@ -134,6 +179,15 @@ public static class NotificationCommands
});
group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMTP configuration" };
foreach (var option in new Option[] { SmtpServerOption, SmtpPortOption, SmtpAuthModeOption, SmtpFromOption, SmtpTlsModeOption,
SmtpCredentialsOption, SmtpOAuth2AuthorityOption, SmtpOAuth2ScopeOption, SmtpTransportOption }) createCmd.Add(option);
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmtpConfigCommand(result.GetValue(SmtpServerOption)!, result.GetValue(SmtpPortOption), result.GetValue(SmtpAuthModeOption)!,
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption), result.GetValue(SmtpCredentialsOption),
result.GetValue(SmtpOAuth2AuthorityOption), result.GetValue(SmtpOAuth2ScopeOption), result.GetValue(SmtpTransportOption))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
updateCmd.Add(SmtpIdOption);
updateCmd.Add(SmtpServerOption);
@@ -247,6 +301,21 @@ public static class NotificationCommands
});
group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMS configuration (Auth Token required)" };
foreach (var option in new Option[] { SmsAccountSidOption, SmsFromNumberOption, SmsMessagingServiceSidOption, SmsApiBaseUrlOption,
SmsAuthTokenOption, SmsTimeoutOption, SmsMaxRetriesOption, SmsRetryDelayOption }) createCmd.Add(option);
createCmd.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption))) result.AddError("--auth-token is required when creating SMS configuration.");
if (string.IsNullOrWhiteSpace(result.GetValue(SmsFromNumberOption)) && string.IsNullOrWhiteSpace(result.GetValue(SmsMessagingServiceSidOption)))
result.AddError("Provide --from-number and/or --messaging-service-sid.");
});
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmsConfigCommand(result.GetValue(SmsAccountSidOption)!, result.GetValue(SmsFromNumberOption), result.GetValue(SmsMessagingServiceSidOption),
result.GetValue(SmsApiBaseUrlOption), result.GetValue(SmsAuthTokenOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMS configuration" };
updateCmd.Add(SmsIdOption);
updateCmd.Add(SmsAccountSidOption);
@@ -254,6 +323,9 @@ public static class NotificationCommands
updateCmd.Add(SmsMessagingServiceSidOption);
updateCmd.Add(SmsApiBaseUrlOption);
updateCmd.Add(SmsAuthTokenOption);
updateCmd.Add(SmsTimeoutOption);
updateCmd.Add(SmsMaxRetriesOption);
updateCmd.Add(SmsRetryDelayOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -265,6 +337,24 @@ public static class NotificationCommands
return group;
}
private static readonly Option<int?> SmsTimeoutOption = SmsPolicyOption("--connection-timeout-seconds", "Connection timeout in seconds", 1);
private static readonly Option<int?> SmsMaxRetriesOption = SmsPolicyOption("--max-retries", "Retry count (zero uses the outbox fallback)", 0);
private static readonly Option<int?> SmsRetryDelayOption = SmsPolicyOption("--retry-delay-seconds", "Retry delay in seconds (zero uses the outbox fallback)", 0);
private static Option<int?> SmsPolicyOption(string name, string description, int minimum)
{
var option = new Option<int?>(name) { Description = description + "; omission preserves updates or uses create defaults" };
option.Validators.Add(result =>
{
if (result.GetValueOrDefault<int?>() is { } value && value < minimum)
result.AddError($"{name} must be at least {minimum}.");
});
return option;
}
private static TimeSpan? SmsRetryDelay(ParseResult result) =>
result.GetValue(SmsRetryDelayOption) is { } seconds ? TimeSpan.FromSeconds(seconds) : null;
// SMS update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmsConfigCommand (used by tests).
private static readonly Option<int> SmsIdOption =
@@ -317,7 +407,8 @@ public static class NotificationCommands
throw new ArgumentException(
"Provide --from-number and/or --messaging-service-sid (at least one sender identity is required).");
}
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken);
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken,
result.GetValue(SmsTimeoutOption), result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result));
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
@@ -1,15 +1,14 @@
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>
/// Builds the <c>secured-write</c> command group. Currently exposes the read-only
/// <c>list</c> sub-command, which relays a <see cref="ListSecuredWritesCommand"/> to the
/// central <c>ManagementActor</c> (gated any-of <c>Operator</c> / <c>Verifier</c> /
/// <c>Administrator</c>). The list is offset-paged via <c>--skip</c> / <c>--take</c>
/// (arch-review P3); omitting them preserves the historical unpaged default (first 200 rows).
/// Builds the two-person secured-write workflow. Submission requires an Operator;
/// approve/reject require a distinct Verifier. Roles, site scope, expiry, and
/// separation of duties are enforced by the management server.
/// </summary>
public static class SecuredWriteCommands
{
@@ -24,7 +23,7 @@ public static class SecuredWriteCommands
private static readonly Option<int> TakeOption =
new("--take") { Description = "Maximum rows to return (clamped to 1..500 server-side)", DefaultValueFactory = _ => 200 };
/// <summary>Builds the <c>secured-write</c> command group with its <c>list</c> sub-command.</summary>
/// <summary>Builds the <c>secured-write</c> command group with list, submit, approve, and reject subcommands.</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>
@@ -32,8 +31,11 @@ public static class SecuredWriteCommands
/// <returns>The configured <c>secured-write</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("secured-write") { Description = "Query two-person (MxGateway) secured writes" };
var command = new Command("secured-write") { Description = "Manage two-person (MxGateway) secured writes" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSubmit(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDecision("approve", urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDecision("reject", urlOption, formatOption, usernameOption, passwordOption));
return command;
}
@@ -53,6 +55,84 @@ public static class SecuredWriteCommands
return cmd;
}
private static Command BuildSubmit(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var site = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var connection = new Option<string>("--connection") { Description = "MxGateway connection name", Required = true };
var tag = new Option<string>("--tag-path") { Description = "Fully-qualified tag path", Required = true };
var value = new Option<string>("--value-json") { Description = "JSON-serialized value (quote JSON strings)", Required = true };
var type = new Option<string>("--value-type") { Description = "Target data type name, e.g. Boolean or Double", Required = true };
var comment = new Option<string?>("--comment") { Description = "Operator comment" };
var command = new Command("submit") { Description = "Submit a pending secured write as an Operator for a distinct Verifier" };
command.Add(site); command.Add(connection); command.Add(tag); command.Add(value); command.Add(type); command.Add(comment);
command.SetAction(async (ParseResult result) =>
{
var json = result.GetValue(value)!;
try
{
using var parsed = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
OutputFormatter.WriteError($"Invalid value JSON: {ex.Message}", "INVALID_JSON");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new SubmitSecuredWriteCommand(result.GetValue(site)!, result.GetValue(connection)!, result.GetValue(tag)!, json,
result.GetValue(type)!, result.GetValue(comment)));
});
return command;
}
private static Command BuildDecision(string verb, Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var id = new Option<long>("--id") { Description = "Pending secured-write ID", Required = true };
var comment = new Option<string?>("--comment") { Description = "Verifier comment" };
var command = new Command(verb)
{
Description = verb == "approve"
? "Approve and execute a pending write as a distinct Verifier"
: "Reject a pending write as a distinct Verifier"
};
command.Add(id); command.Add(comment);
command.SetAction((ParseResult result) =>
{
object request = verb == "approve"
? new ApproveSecuredWriteCommand(result.GetValue(id), result.GetValue(comment))
: new RejectSecuredWriteCommand(result.GetValue(id), result.GetValue(comment));
return CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption, request,
onSuccess: verb == "approve"
? json => HandleApprovalResponse(json, CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load()))
: null);
});
return command;
}
private static int HandleApprovalResponse(string json, string format)
{
CommandHelpers.HandleResponse(new ManagementResponse(200, json, null, null), format);
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("status", out var status)
&& status.ValueKind == JsonValueKind.String)
{
if (status.GetString() == "Executed") return 0;
if (status.GetString() == "Failed")
{
var detail = root.TryGetProperty("executionError", out var error) && error.ValueKind == JsonValueKind.String
? error.GetString() : null;
OutputFormatter.WriteError(detail ?? "The server reported that the secured write failed.", "SECURED_WRITE_FAILED");
return 1;
}
}
}
catch (JsonException) { }
OutputFormatter.WriteError("The server returned an invalid secured-write approval outcome.", "INVALID_RESPONSE");
return 1;
}
/// <summary>
/// Builds a <see cref="ListSecuredWritesCommand"/> from a parsed <c>secured-write list</c>
/// invocation. Exposed internally so command-construction tests can assert field mapping
@@ -0,0 +1,81 @@
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>CLI adapters for the shared JSON schema library management contracts.</summary>
public static class SharedSchemaCommands
{
/// <summary>Builds the shared-schema list/get/create/update/delete command group.</summary>
/// <param name="urlOption">Management URL option.</param>
/// <param name="formatOption">Output format option.</param>
/// <param name="usernameOption">Authentication username option.</param>
/// <param name="passwordOption">Authentication password option.</param>
/// <returns>The configured shared-schema group.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("shared-schema") { Description = "Manage the shared JSON schema library" };
var list = new Command("list") { Description = "List shared schemas" };
list.SetAction((ParseResult result) => Send(result, new ListSharedSchemasCommand()));
group.Add(list);
group.Add(BuildIdentity("get", id => new GetSharedSchemaCommand(id)));
group.Add(BuildEdit(update: false));
group.Add(BuildEdit(update: true));
group.Add(BuildIdentity("delete", id => new DeleteSharedSchemaCommand(id)));
return group;
Task<int> Send(ParseResult result, object command) => CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
Command BuildIdentity(string verb, Func<int, object> makeCommand)
{
var id = new Option<int>("--id") { Description = "Shared schema ID", Required = true };
var command = new Command(verb) { Description = $"{(verb == "get" ? "Get" : "Delete")} a shared schema by ID" };
command.Add(id);
command.SetAction((ParseResult result) => Send(result, makeCommand(result.GetValue(id))));
return command;
}
Command BuildEdit(bool update)
{
var id = new Option<int>("--id") { Description = "Shared schema ID", Required = true };
var name = new Option<string>("--name") { Description = "Schema library name", Required = true };
var scope = new Option<string?>("--scope") { Description = "Optional schema scope; omission clears scope on update" };
var file = new Option<string>("--schema-file") { Description = "Path to the JSON schema document", Required = true };
var command = new Command(update ? "update" : "create")
{
Description = update ? "Replace a shared schema's name, scope, and JSON document" : "Create a shared schema from a JSON file"
};
if (update) command.Add(id);
command.Add(name); command.Add(scope); command.Add(file);
command.SetAction(async (ParseResult result) =>
{
string json;
try
{
json = await File.ReadAllTextAsync(result.GetValue(file)!);
using var parsed = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
OutputFormatter.WriteError($"Invalid schema JSON: {ex.Message}", "INVALID_JSON");
return 1;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
OutputFormatter.WriteError($"Could not read schema file: {ex.Message}", "FILE_ERROR");
return 1;
}
// Structural schema validation, uniqueness, and authorization remain server-owned.
object request = update
? new UpdateSharedSchemaCommand(result.GetValue(id), result.GetValue(name)!, result.GetValue(scope), json)
: new CreateSharedSchemaCommand(result.GetValue(name)!, result.GetValue(scope), json);
return await Send(result, request);
});
return command;
}
}
}
@@ -19,6 +19,8 @@ public static class TemplateCommands
var command = new Command("template") { Description = "Manage templates" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildResolvedMembers(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildMove(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));
@@ -139,6 +141,65 @@ public static class TemplateCommands
return group;
}
private static Command BuildResolvedMembers(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("resolved-members") { Description = "Inspect resolved template members including inherited members" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new GetResolvedTemplateMembersCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildMove(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var folderOption = new Option<int?>("--folder-id") { Description = "Destination folder ID; omit to move to the tree root" };
var cmd = new Command("move") { Description = "Move a template into a folder or to the tree root" };
cmd.Add(idOption);
cmd.Add(folderOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new MoveTemplateToFolderCommand(result.GetValue(idOption), result.GetValue(folderOption)));
});
return cmd;
}
private static Command BuildUpdateNativeAlarmSource(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Native alarm source ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Source binding name", Required = true };
var connectionOption = new Option<string>("--connection") { Description = "Alarm-capable data connection name", Required = true };
var sourceOption = new Option<string>("--source-ref") { Description = "Source reference (OPC UA SourceNode nodeId, or MxAccess object/area)", Required = true };
var filterOption = new Option<string?>("--filter") { Description = "Condition filter; omit to clear and mirror all conditions" };
var descriptionOption = new Option<string?>("--description") { Description = "Description; omit to clear" };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status; omit for unlocked" };
var cmd = new Command("update") { Description = "Replace the fields of a native alarm source binding" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(connectionOption);
cmd.Add(sourceOption);
cmd.Add(filterOption);
cmd.Add(descriptionOption);
cmd.Add(lockedOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateNativeAlarmSourceCommand(
result.GetValue(idOption), result.GetValue(nameOption)!,
result.GetValue(connectionOption)!, result.GetValue(sourceOption)!,
result.GetValue(filterOption), result.GetValue(descriptionOption), result.GetValue(lockedOption)));
});
return cmd;
}
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
@@ -758,6 +819,8 @@ public static class TemplateCommands
Description = "Manage template native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)"
};
group.Add(BuildUpdateNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
// add
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Source binding name", Required = true };
+2 -44
View File
@@ -1,50 +1,8 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.CLI;
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));
var dataConnectionCommand = DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption);
// Attach the site-routed browse/search/verify-endpoint/certs subcommands (actor parity, arch-review C4).
ConnectionBrowseCommands.AddTo(dataConnectionCommand, urlOption, formatOption, usernameOption, passwordOption);
rootCommand.Add(dataConnectionCommand);
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.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
});
var rootCommand = CliApplication.BuildRoot();
// Deprecation notice for the earlier `audit-log` command name. The command itself
// still works (it is an alias of `audit-config`), but using the old name emits a
+102 -1
View File
@@ -2272,7 +2272,7 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
### `secured-write` — Query two-person write requests
`secured-write list` reads the MxGateway secured-write queue and terminal history. It requires any of Operator, Verifier or Administrator and applies the caller's site scope. This CLI group exposes listing only; submitting, approving and rejecting are management/UI operations.
`secured-write list` reads the MxGateway secured-write queue and terminal history. It requires any of Operator, Verifier or Administrator and applies the caller's site scope. This group also exposes `submit`, `approve`, and `reject` through the same server-side authorization and separation-of-duties checks as the UI; see the parity additions below.
```sh
scadabridge secured-write list --status Pending --site site-a --skip 0 --take 50
@@ -2424,3 +2424,104 @@ Role enforcement is applied by the ManagementActor on the server side. The CLI a
## Issues & Missing Features
If you encounter bugs, unexpected behavior, or missing features in the CLI, log them in [`cli_issues.md`](../../cli_issues.md) in the project root. Include a brief description, the command involved, and any relevant error output.
## Management parity additions (2026-09-10)
The production CLI exposes all 149 HTTP-eligible management contracts across 20
command groups. `CliApplication.BuildRoot()` is used both by the executable and
by the HTTP-dispatch parity test. Site-only `ReadTagValues` and the retired
`ResolveRoles` flow are not accepted by `POST /management`; binding tests continue
using the site's existing transport.
### Shared schema library
```sh
scadabridge shared-schema list
scadabridge shared-schema get --id 7
scadabridge shared-schema create --name PumpPayload --schema-file ./pump.schema.json --scope Pump
scadabridge shared-schema update --id 7 --name PumpPayload --schema-file ./pump.schema.json
scadabridge shared-schema delete --id 7
```
Create/update read UTF-8 JSON from `--schema-file`; the server validates schema
structure and uniqueness. Update replaces the name, schema, and scope; omitting
`--scope` clears it. Mutations require Designer access.
### Individual notification edits and provider creation
```sh
scadabridge notification rename --id 4 --name "Shift engineers"
scadabridge notification recipient add --list-id 4 --name "On-call engineer" --email engineer@example.com
scadabridge notification recipient delete --id 18
scadabridge notification smtp create --server smtp.example.com --port 587 --auth-mode None --from-address alerts@example.com
scadabridge notification sms create --account-sid AC0123456789abcdef0123456789abcdef --from-number +15551234567 --auth-token "<token>"
```
Rename and individual recipient edits preserve unrelated recipient IDs and names.
Recipient add accepts `--email` and/or `--phone`; the server checks them against
its owning list's channel. Existing `notification update` retains its whole-list
recipient replacement semantics.
SMTP/SMS create accept their update options except `--id` and send distinct create
contracts. SMS create requires an auth token and a sender (`--from-number` and/or
`--messaging-service-sid`). Provider creation/update requires Administrator access.
SMS create and update also accept:
| Option | Meaning |
|---|---|
| `--connection-timeout-seconds` | Positive connection timeout |
| `--max-retries` | Nonnegative retry setting |
| `--retry-delay-seconds` | Nonnegative retry delay |
Omission preserves existing update values or uses creation defaults. Explicit
zero retry settings retain the existing outbox fallback semantics; they do not
promise that delivery will never retry. Existing secret-preservation and
messaging-service-ID clearing behavior on update is unchanged.
### Template, instance, and connection edits
```sh
scadabridge template resolved-members --id 3
scadabridge template move --id 3 --folder-id 8
scadabridge template native-alarm-source update --id 5 --name Pressure --connection Plc --source-ref Pressure --locked
scadabridge instance attribute-override delete --instance-id 7 --attribute Module.Speed
scadabridge instance native-alarm-source list --instance-id 7
scadabridge data-connection move --id 9 --site-id 2
```
`resolved-members` inspects effective inherited template members, without applying
composition or instance overrides. Omit `--folder-id` to move a template to the
root. Native-alarm-source update replaces all contract fields: optional `--filter`
and `--description` clear when omitted, and omitted `--locked` means false.
Individual attribute-override deletion preserves other overrides. Server-side
role, site-scope, locking, and dependency checks remain authoritative.
### Secured-write lifecycle
```sh
scadabridge secured-write submit --site site-a --connection Plc --tag-path Pump.Setpoint --value-json 42 --value-type Int32 --comment "Approved operating procedure"
scadabridge secured-write approve --id 123 --comment "Verified setpoint"
scadabridge secured-write reject --id 124 --comment "Incorrect target"
```
Submit requires Operator access; approve/reject require Verifier access. The
server derives identity from authentication and enforces a different principal
for the verification decision. Approval can execute a real device write.
`--value-json` must be valid JSON; the server validates the value/type pairing.
IDs for approve/reject are 64-bit integers; `--comment` is optional.
Approval exits zero only for an `Executed` response. A server-reported execution
failure exits one while retaining the response details; malformed or unexpected
approval responses also fail. Authorization denial exits two. JSON/table output
continues to use the standard formatter.
### Deployment deadline policy
`deploy instance`, `instance deploy`, `deploy artifacts`, `site deploy-artifacts`,
and `deploy site` defer the overall deadline to the Management API. They keep the
bounded socket-connect timeout but do not impose the ordinary 30-second client
request deadline on a deployment that may still be applying. The API's configured
long-running timeout remains authoritative. Ordinary commands retain a 30-second
client deadline; explicit bundle/Flow Debugger timeouts retain their own policies.
@@ -4,9 +4,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <summary>
/// Bidirectional name registry for management command records. The registry contains
/// exactly the non-abstract <c>*Command</c> types declared in the
/// the HTTP-eligible non-abstract <c>*Command</c> types declared in the
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Messages.Management</c> namespace; these are the commands that
/// travel over the HTTP management boundary.
/// travel over the HTTP management boundary. Site-only tag reads and the retired
/// role-resolution flow are excluded; their internal message types remain available.
/// </summary>
/// <remarks>
/// <see cref="Resolve"/> and <see cref="GetCommandName"/> are symmetric:
@@ -41,7 +42,7 @@ public static class ManagementCommandRegistry
/// <param name="commandType">The CLR type of the management command to look up.</param>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="commandType"/> is not a registered management
/// command — i.e. not a non-abstract <c>*Command</c> type in the
/// command — i.e. not an HTTP-eligible non-abstract <c>*Command</c> type in the
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Messages.Management</c> namespace. This keeps the method
/// symmetric with <see cref="Resolve"/>: it never yields a name that
/// <see cref="Resolve"/> cannot turn back into the same type.
@@ -56,7 +57,7 @@ public static class ManagementCommandRegistry
throw new ArgumentException(
$"'{commandType.FullName}' is not a registered management command. " +
$"Management commands must be non-abstract '*Command' records declared in " +
$"Management commands must be HTTP-eligible, non-abstract '*Command' records declared in " +
$"the '{typeof(ManagementEnvelope).Namespace}' namespace.",
nameof(commandType));
}
@@ -69,7 +70,12 @@ public static class ManagementCommandRegistry
return assembly.GetTypes()
.Where(t => t.Namespace == ns
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract)
&& !t.IsAbstract
// These records are internal/retired, not central HTTP operations.
// Keep the types for site gRPC and compatibility, without advertising
// names that ManagementActor cannot dispatch.
&& t != typeof(ReadTagValuesCommand)
&& t != typeof(ResolveRolesCommand))
.ToFrozenDictionary(
t => t.Name[..^"Command".Length],
t => t,