feat(m9): CLI cached-call retry/discard command group
Adds `cached-call retry` and `cached-call discard` subcommands that relay to the existing Deployer-gated RetryParkedMessageCommand / DiscardParkedMessageCommand via the central SiteCallAuditActor → site relay. ManagementCommandRegistry already covered both types via reflection auto-discovery. CommandTreeTests updated to include cached-call (group count 16 → 17).
This commit is contained in:
@@ -339,6 +339,21 @@ On import, the mapping flags reconcile environment-specific identifiers. `--map-
|
|||||||
Inbound API keys are not transported between environments — re-create them on the destination via CLI or UI.
|
Inbound API keys are not transported between environments — re-create them on the destination via CLI or UI.
|
||||||
Bundle commands use a 5-minute timeout.
|
Bundle commands use a 5-minute timeout.
|
||||||
|
|
||||||
|
### Cached-Call Commands (Site Call Audit #22)
|
||||||
|
|
||||||
|
Retry or discard parked `ExternalSystem.CachedCall` / `Database.CachedWrite` operations
|
||||||
|
at a site. Both commands relay via the central `SiteCallAuditActor` → site relay and
|
||||||
|
require the **Deployer** role (same gate as the Central UI Site Calls page).
|
||||||
|
|
||||||
|
```
|
||||||
|
scadabridge cached-call retry --site-id <identifier> --tracked-operation-id <id>
|
||||||
|
scadabridge cached-call discard --site-id <identifier> --tracked-operation-id <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--tracked-operation-id` value corresponds to the `MessageId` field of the parked
|
||||||
|
`SiteCall` row (visible on the Central UI Site Calls page and in the `SiteCalls` audit
|
||||||
|
table).
|
||||||
|
|
||||||
The `--format json|table` option is recursive and accepted on every command above.
|
The `--format json|table` option is recursive and accepted on every command above.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using System.CommandLine.Parsing;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <c>cached-call</c> command group with sub-commands for retrying and
|
||||||
|
/// discarding parked cached-call operations. Both sub-commands relay to the existing
|
||||||
|
/// Deployer-gated <see cref="RetryParkedMessageCommand"/> /
|
||||||
|
/// <see cref="DiscardParkedMessageCommand"/> management commands via the central
|
||||||
|
/// <c>SiteCallAuditActor</c> → site relay.
|
||||||
|
/// </summary>
|
||||||
|
public static class CachedCallCommands
|
||||||
|
{
|
||||||
|
// Options are static so the parsed values can be read back from both SetAction
|
||||||
|
// and the internal BuildRetryCommand / BuildDiscardCommand helpers (used by tests).
|
||||||
|
private static readonly Option<string> RetrySiteIdOption =
|
||||||
|
new("--site-id") { Description = "Site identifier of the parked operation", Required = true };
|
||||||
|
private static readonly Option<string> RetryMessageIdOption =
|
||||||
|
new("--tracked-operation-id") { Description = "Tracked operation ID (MessageId) of the parked cached call", Required = true };
|
||||||
|
|
||||||
|
private static readonly Option<string> DiscardSiteIdOption =
|
||||||
|
new("--site-id") { Description = "Site identifier of the parked operation", Required = true };
|
||||||
|
private static readonly Option<string> DiscardMessageIdOption =
|
||||||
|
new("--tracked-operation-id") { Description = "Tracked operation ID (MessageId) of the parked cached call", Required = true };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <c>cached-call</c> command group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
|
||||||
|
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
|
||||||
|
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
|
||||||
|
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
|
||||||
|
/// <returns>The configured <c>cached-call</c> command with <c>retry</c> and <c>discard</c> sub-commands.</returns>
|
||||||
|
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var command = new Command("cached-call") { Description = "Manage parked cached-call operations (ExternalSystem.CachedCall / Database.CachedWrite)" };
|
||||||
|
|
||||||
|
command.Add(BuildRetry(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
command.Add(BuildDiscard(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildRetry(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var cmd = new Command("retry") { Description = "Retry a parked cached-call operation" };
|
||||||
|
cmd.Add(RetrySiteIdOption);
|
||||||
|
cmd.Add(RetryMessageIdOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
await CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
BuildRetryCommand(result)));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildDiscard(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var cmd = new Command("discard") { Description = "Discard a parked cached-call operation" };
|
||||||
|
cmd.Add(DiscardSiteIdOption);
|
||||||
|
cmd.Add(DiscardMessageIdOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
await CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
BuildDiscardCommand(result)));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="RetryParkedMessageCommand"/> from a parsed <c>cached-call retry</c>
|
||||||
|
/// invocation. Exposed internally so command-construction tests can assert field mapping
|
||||||
|
/// without triggering the HTTP call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The parsed command-line result from the <c>cached-call retry</c> invocation.</param>
|
||||||
|
/// <returns>A <see cref="RetryParkedMessageCommand"/> populated from the parsed result.</returns>
|
||||||
|
internal static RetryParkedMessageCommand BuildRetryCommand(ParseResult result)
|
||||||
|
{
|
||||||
|
var siteId = result.GetValue(RetrySiteIdOption)!;
|
||||||
|
var messageId = result.GetValue(RetryMessageIdOption)!;
|
||||||
|
return new RetryParkedMessageCommand(siteId, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="DiscardParkedMessageCommand"/> from a parsed <c>cached-call discard</c>
|
||||||
|
/// invocation. Exposed internally so command-construction tests can assert field mapping
|
||||||
|
/// without triggering the HTTP call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The parsed command-line result from the <c>cached-call discard</c> invocation.</param>
|
||||||
|
/// <returns>A <see cref="DiscardParkedMessageCommand"/> populated from the parsed result.</returns>
|
||||||
|
internal static DiscardParkedMessageCommand BuildDiscardCommand(ParseResult result)
|
||||||
|
{
|
||||||
|
var siteId = result.GetValue(DiscardSiteIdOption)!;
|
||||||
|
var messageId = result.GetValue(DiscardMessageIdOption)!;
|
||||||
|
return new DiscardParkedMessageCommand(siteId, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOpti
|
|||||||
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
rootCommand.SetAction(_ =>
|
rootCommand.SetAction(_ =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1743,6 +1743,40 @@ Site identifiers and connection names are environment-specific; the importer fir
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `cached-call` — Retry or discard parked cached-call operations
|
||||||
|
|
||||||
|
Manage parked `ExternalSystem.CachedCall` and `Database.CachedWrite` operations at a
|
||||||
|
site. Both sub-commands relay via the central `SiteCallAuditActor` → site relay and
|
||||||
|
require the **Deployer** role.
|
||||||
|
|
||||||
|
#### `cached-call retry`
|
||||||
|
|
||||||
|
Retry a parked cached-call operation at the specified site.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> cached-call retry --site-id <string> --tracked-operation-id <string>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site-id` | yes | Site identifier where the operation is parked |
|
||||||
|
| `--tracked-operation-id` | yes | Tracked operation ID (MessageId) of the parked cached call |
|
||||||
|
|
||||||
|
#### `cached-call discard`
|
||||||
|
|
||||||
|
Discard a parked cached-call operation at the specified site.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> cached-call discard --site-id <string> --tracked-operation-id <string>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site-id` | yes | Site identifier where the operation is parked |
|
||||||
|
| `--tracked-operation-id` | yes | Tracked operation ID (MessageId) of the parked cached call |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
The CLI connects to the Central cluster using Akka.NET's `ClusterClient`. It does not join the cluster — it contacts the `ClusterClientReceptionist` on one of the configured Central nodes and sends commands to the `ManagementActor` at path `/user/management`.
|
The CLI connects to the Central cluster using Akka.NET's `ClusterClient`. It does not join the cluster — it contacts the `ClusterClientReceptionist` on one of the configured Central nodes and sends commands to the `ManagementActor` at path `/user/management`.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class CommandTreeTests
|
|||||||
DbConnectionCommands.Build(Url, Format, Username, Password),
|
DbConnectionCommands.Build(Url, Format, Username, Password),
|
||||||
ApiMethodCommands.Build(Url, Format, Username, Password),
|
ApiMethodCommands.Build(Url, Format, Username, Password),
|
||||||
BundleCommands.Build(Url, Format, Username, Password),
|
BundleCommands.Build(Url, Format, Username, Password),
|
||||||
|
CachedCallCommands.Build(Url, Format, Username, Password),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static IEnumerable<Command> LeafCommands(Command command)
|
private static IEnumerable<Command> LeafCommands(Command command)
|
||||||
@@ -60,11 +61,11 @@ public class CommandTreeTests
|
|||||||
{
|
{
|
||||||
var groups = AllCommandGroups().ToList();
|
var groups = AllCommandGroups().ToList();
|
||||||
// CLI-022: bump this count whenever a new top-level command group is
|
// CLI-022: bump this count whenever a new top-level command group is
|
||||||
// registered in Program.cs. Current registered groups (16):
|
// registered in Program.cs. Current registered groups (17):
|
||||||
// template, instance, site, deploy, data-connection, external-system,
|
// template, instance, site, deploy, data-connection, external-system,
|
||||||
// notification, security, audit-config, audit, health, debug,
|
// notification, security, audit-config, audit, health, debug,
|
||||||
// shared-script, db-connection, api-method, bundle.
|
// shared-script, db-connection, api-method, bundle, cached-call.
|
||||||
Assert.Equal(16, groups.Count);
|
Assert.Equal(17, groups.Count);
|
||||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using System.CommandLine.Parsing;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="CachedCallCommands"/> — pins that option parsing maps to the
|
||||||
|
/// correct management command objects with the correct field values.
|
||||||
|
/// </summary>
|
||||||
|
public class CachedCallCommandsTests
|
||||||
|
{
|
||||||
|
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||||
|
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||||
|
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||||
|
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
|
||||||
|
|
||||||
|
private static Command BuildCachedCall()
|
||||||
|
=> CachedCallCommands.Build(Url, Format, Username, Password);
|
||||||
|
|
||||||
|
// ── retry ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var retry = group.Subcommands.Single(c => c.Name == "retry");
|
||||||
|
|
||||||
|
var result = retry.Parse(
|
||||||
|
["--site-id", "site-a", "--tracked-operation-id", "11111111-2222-3333-4444-555555555555"]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
var cmd = CachedCallCommands.BuildRetryCommand(result);
|
||||||
|
|
||||||
|
Assert.Equal("site-a", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retry_SiteIdAndTrackedOperationId_AreRequired()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var retry = group.Subcommands.Single(c => c.Name == "retry");
|
||||||
|
|
||||||
|
var siteId = retry.Options.Single(o => o.Name == "--site-id");
|
||||||
|
var opId = retry.Options.Single(o => o.Name == "--tracked-operation-id");
|
||||||
|
|
||||||
|
Assert.True(siteId.Required, "--site-id must be required.");
|
||||||
|
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retry_MissingOptions_ProducesParseErrors()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var retry = group.Subcommands.Single(c => c.Name == "retry");
|
||||||
|
|
||||||
|
var result = retry.Parse([]);
|
||||||
|
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── discard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var discard = group.Subcommands.Single(c => c.Name == "discard");
|
||||||
|
|
||||||
|
var result = discard.Parse(
|
||||||
|
["--site-id", "site-b", "--tracked-operation-id", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
var cmd = CachedCallCommands.BuildDiscardCommand(result);
|
||||||
|
|
||||||
|
Assert.Equal("site-b", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Discard_SiteIdAndTrackedOperationId_AreRequired()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var discard = group.Subcommands.Single(c => c.Name == "discard");
|
||||||
|
|
||||||
|
var siteId = discard.Options.Single(o => o.Name == "--site-id");
|
||||||
|
var opId = discard.Options.Single(o => o.Name == "--tracked-operation-id");
|
||||||
|
|
||||||
|
Assert.True(siteId.Required, "--site-id must be required.");
|
||||||
|
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Discard_MissingOptions_ProducesParseErrors()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var discard = group.Subcommands.Single(c => c.Name == "discard");
|
||||||
|
|
||||||
|
var result = discard.Parse([]);
|
||||||
|
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── group shape ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CachedCallGroup_HasRetryAndDiscard()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
|
||||||
|
|
||||||
|
Assert.Contains("retry", subNames);
|
||||||
|
Assert.Contains("discard", subNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CachedCallGroup_Name_IsCachedCall()
|
||||||
|
{
|
||||||
|
var group = BuildCachedCall();
|
||||||
|
Assert.Equal("cached-call", group.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── registry round-trip ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetryParkedMessageCommand_ResolvesViaRegistry()
|
||||||
|
{
|
||||||
|
var name = ManagementCommandRegistry.GetCommandName(typeof(RetryParkedMessageCommand));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(name));
|
||||||
|
Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscardParkedMessageCommand_ResolvesViaRegistry()
|
||||||
|
{
|
||||||
|
var name = ManagementCommandRegistry.GetCommandName(typeof(DiscardParkedMessageCommand));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(name));
|
||||||
|
Assert.Equal(typeof(DiscardParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user