Complete management API and CLI command parity
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user